Browse Source

Merge branch 'develop'

Ralfp 12 years ago
parent
commit
3945d2bca0
522 changed files with 9521 additions and 7787 deletions
  1. 1 0
      .gitignore
  2. 7 0
      .travis.yml
  3. 21 15
      README.md
  4. 98 0
      README.md.orig
  5. 6 3
      cron.txt
  6. 11 1
      deployment/settings.py
  7. 1 23
      misago/__init__.py
  8. 6 6
      misago/acl/builder.py
  9. 0 7
      misago/acl/context_processors.py
  10. 9 0
      misago/acl/exceptions.py
  11. 0 9
      misago/acl/fixtures.py
  12. 0 0
      misago/acl/permissions/__init__.py
  13. 1 1
      misago/acl/permissions/forums.py
  14. 124 0
      misago/acl/permissions/privatethreads.py
  15. 41 0
      misago/acl/permissions/special.py
  16. 23 11
      misago/acl/permissions/threads.py
  17. 14 11
      misago/acl/permissions/usercp.py
  18. 12 9
      misago/acl/permissions/users.py
  19. 0 17
      misago/acl/utils.py
  20. 3 2
      misago/admin.py
  21. 0 29
      misago/admin/acl.py
  22. 0 4
      misago/admin/context_processors.py
  23. 0 119
      misago/alerts/migrations/0001_initial.py
  24. 0 0
      misago/apps/__init__.py
  25. 0 0
      misago/apps/activation/__init__.py
  26. 4 6
      misago/apps/activation/forms.py
  27. 1 1
      misago/apps/activation/urls.py
  28. 9 14
      misago/apps/activation/views.py
  29. 0 0
      misago/apps/admin/__init__.py
  30. 0 0
      misago/apps/admin/bans/__init__.py
  31. 14 14
      misago/apps/admin/bans/forms.py
  32. 13 30
      misago/apps/admin/bans/views.py
  33. 0 0
      misago/apps/admin/clients/__init__.py
  34. 2 2
      misago/apps/admin/clients/forms.py
  35. 12 11
      misago/apps/admin/clients/views.py
  36. 0 0
      misago/apps/admin/forumroles/__init__.py
  37. 1 1
      misago/apps/admin/forumroles/forms.py
  38. 8 7
      misago/apps/admin/forumroles/views.py
  39. 0 0
      misago/apps/admin/forums/__init__.py
  40. 10 10
      misago/apps/admin/forums/forms.py
  41. 10 9
      misago/apps/admin/forums/views.py
  42. 2 1
      misago/apps/admin/home.py
  43. 15 0
      misago/apps/admin/index.py
  44. 0 0
      misago/apps/admin/newsletters/__init__.py
  45. 2 2
      misago/apps/admin/newsletters/forms.py
  46. 4 5
      misago/apps/admin/newsletters/views.py
  47. 0 0
      misago/apps/admin/online/__init__.py
  48. 0 0
      misago/apps/admin/online/forms.py
  49. 2 2
      misago/apps/admin/online/views.py
  50. 0 0
      misago/apps/admin/pruneusers/__init__.py
  51. 1 1
      misago/apps/admin/pruneusers/forms.py
  52. 4 4
      misago/apps/admin/pruneusers/views.py
  53. 0 0
      misago/apps/admin/ranks/__init__.py
  54. 1 1
      misago/apps/admin/ranks/forms.py
  55. 7 6
      misago/apps/admin/ranks/views.py
  56. 0 0
      misago/apps/admin/roles/__init__.py
  57. 1 1
      misago/apps/admin/roles/forms.py
  58. 15 27
      misago/apps/admin/roles/views.py
  59. 0 0
      misago/apps/admin/sections/__init__.py
  60. 6 6
      misago/apps/admin/sections/forums.py
  61. 10 11
      misago/apps/admin/sections/overview.py
  62. 3 5
      misago/apps/admin/sections/perms.py
  63. 3 3
      misago/apps/admin/sections/system.py
  64. 8 12
      misago/apps/admin/sections/users.py
  65. 0 0
      misago/apps/admin/settings/__init__.py
  66. 0 0
      misago/apps/admin/settings/forms.py
  67. 7 8
      misago/apps/admin/settings/views.py
  68. 0 0
      misago/apps/admin/stats/__init__.py
  69. 0 0
      misago/apps/admin/stats/forms.py
  70. 2 2
      misago/apps/admin/stats/views.py
  71. 1 1
      misago/apps/admin/team.py
  72. 0 0
      misago/apps/admin/users/__init__.py
  73. 2 4
      misago/apps/admin/users/forms.py
  74. 10 10
      misago/apps/admin/users/views.py
  75. 1 3
      misago/apps/admin/widgets.py
  76. 3 3
      misago/apps/alerts.py
  77. 22 0
      misago/apps/category.py
  78. 53 0
      misago/apps/errors.py
  79. 9 0
      misago/apps/forummap.py
  80. 69 0
      misago/apps/index.py
  81. 3 3
      misago/apps/newsfeed.py
  82. 22 0
      misago/apps/newthreads.py
  83. 22 0
      misago/apps/popularthreads.py
  84. 0 0
      misago/apps/privatethreads/__init__.py
  85. 15 0
      misago/apps/privatethreads/changelog.py
  86. 17 0
      misago/apps/privatethreads/delete.py
  87. 9 0
      misago/apps/privatethreads/details.py
  88. 62 0
      misago/apps/privatethreads/forms.py
  89. 115 0
      misago/apps/privatethreads/jumps.py
  90. 29 0
      misago/apps/privatethreads/list.py
  91. 39 0
      misago/apps/privatethreads/mixins.py
  92. 85 0
      misago/apps/privatethreads/posting.py
  93. 57 0
      misago/apps/privatethreads/thread.py
  94. 32 0
      misago/apps/privatethreads/urls.py
  95. 0 0
      misago/apps/profiles/__init__.py
  96. 3 3
      misago/apps/profiles/decorators.py
  97. 0 0
      misago/apps/profiles/details/__init__.py
  98. 0 0
      misago/apps/profiles/details/profile.py
  99. 2 2
      misago/apps/profiles/details/urls.py
  100. 2 2
      misago/apps/profiles/details/views.py
  101. 0 0
      misago/apps/profiles/followers/__init__.py
  102. 0 0
      misago/apps/profiles/followers/profile.py
  103. 2 2
      misago/apps/profiles/followers/urls.py
  104. 4 3
      misago/apps/profiles/followers/views.py
  105. 0 0
      misago/apps/profiles/follows/__init__.py
  106. 0 0
      misago/apps/profiles/follows/profile.py
  107. 2 2
      misago/apps/profiles/follows/urls.py
  108. 4 3
      misago/apps/profiles/follows/views.py
  109. 1 2
      misago/apps/profiles/forms.py
  110. 0 0
      misago/apps/profiles/posts/__init__.py
  111. 0 0
      misago/apps/profiles/posts/profile.py
  112. 2 2
      misago/apps/profiles/posts/urls.py
  113. 5 4
      misago/apps/profiles/posts/views.py
  114. 1 1
      misago/apps/profiles/template.py
  115. 0 0
      misago/apps/profiles/threads/__init__.py
  116. 0 0
      misago/apps/profiles/threads/profile.py
  117. 2 2
      misago/apps/profiles/threads/urls.py
  118. 6 4
      misago/apps/profiles/threads/views.py
  119. 4 4
      misago/apps/profiles/urls.py
  120. 11 9
      misago/apps/profiles/views.py
  121. 23 0
      misago/apps/readall.py
  122. 21 0
      misago/apps/redirect.py
  123. 0 0
      misago/apps/register/__init__.py
  124. 6 8
      misago/apps/register/forms.py
  125. 6 10
      misago/apps/register/views.py
  126. 0 0
      misago/apps/reports/__init__.py
  127. 2 0
      misago/apps/reports/mixins.py
  128. 6 0
      misago/apps/reports/urls.py
  129. 0 0
      misago/apps/resetpswd/__init__.py
  130. 4 6
      misago/apps/resetpswd/forms.py
  131. 1 1
      misago/apps/resetpswd/urls.py
  132. 11 16
      misago/apps/resetpswd/views.py
  133. 0 0
      misago/apps/signin/__init__.py
  134. 1 2
      misago/apps/signin/forms.py
  135. 2 2
      misago/apps/signin/urls.py
  136. 10 14
      misago/apps/signin/views.py
  137. 0 0
      misago/apps/threads/__init__.py
  138. 15 0
      misago/apps/threads/changelog.py
  139. 17 0
      misago/apps/threads/delete.py
  140. 9 0
      misago/apps/threads/details.py
  141. 49 0
      misago/apps/threads/jumps.py
  142. 65 0
      misago/apps/threads/list.py
  143. 8 0
      misago/apps/threads/mixins.py
  144. 39 0
      misago/apps/threads/posting.py
  145. 61 0
      misago/apps/threads/thread.py
  146. 34 0
      misago/apps/threads/urls.py
  147. 0 0
      misago/apps/threadtype/__init__.py
  148. 56 0
      misago/apps/threadtype/base.py
  149. 41 40
      misago/apps/threadtype/changelog.py
  150. 143 0
      misago/apps/threadtype/delete.py
  151. 72 0
      misago/apps/threadtype/details.py
  152. 50 47
      misago/apps/threadtype/jumps.py
  153. 2 0
      misago/apps/threadtype/list/__init__.py
  154. 89 0
      misago/apps/threadtype/list/forms.py
  155. 9 169
      misago/apps/threadtype/list/moderation.py
  156. 127 0
      misago/apps/threadtype/list/views.py
  157. 33 0
      misago/apps/threadtype/mixins.py
  158. 4 0
      misago/apps/threadtype/posting/__init__.py
  159. 135 0
      misago/apps/threadtype/posting/base.py
  160. 54 0
      misago/apps/threadtype/posting/editreply.py
  161. 61 0
      misago/apps/threadtype/posting/editthread.py
  162. 97 0
      misago/apps/threadtype/posting/forms.py
  163. 138 0
      misago/apps/threadtype/posting/newreply.py
  164. 82 0
      misago/apps/threadtype/posting/newthread.py
  165. 3 0
      misago/apps/threadtype/thread/__init__.py
  166. 6 0
      misago/apps/threadtype/thread/forms.py
  167. 0 0
      misago/apps/threadtype/thread/moderation/__init__.py
  168. 69 0
      misago/apps/threadtype/thread/moderation/forms.py
  169. 213 0
      misago/apps/threadtype/thread/moderation/posts.py
  170. 130 0
      misago/apps/threadtype/thread/moderation/thread.py
  171. 210 0
      misago/apps/threadtype/thread/views.py
  172. 2 2
      misago/apps/tos.py
  173. 0 0
      misago/apps/usercp/__init__.py
  174. 0 0
      misago/apps/usercp/avatar/__init__.py
  175. 0 0
      misago/apps/usercp/avatar/forms.py
  176. 3 3
      misago/apps/usercp/avatar/urls.py
  177. 0 0
      misago/apps/usercp/avatar/usercp.py
  178. 12 11
      misago/apps/usercp/avatar/views.py
  179. 0 0
      misago/apps/usercp/credentials/__init__.py
  180. 2 2
      misago/apps/usercp/credentials/forms.py
  181. 5 3
      misago/apps/usercp/credentials/urls.py
  182. 0 0
      misago/apps/usercp/credentials/usercp.py
  183. 6 6
      misago/apps/usercp/credentials/views.py
  184. 0 0
      misago/apps/usercp/options/__init__.py
  185. 53 0
      misago/apps/usercp/options/forms.py
  186. 2 2
      misago/apps/usercp/options/urls.py
  187. 0 0
      misago/apps/usercp/options/usercp.py
  188. 6 5
      misago/apps/usercp/options/views.py
  189. 0 0
      misago/apps/usercp/signature/__init__.py
  190. 0 0
      misago/apps/usercp/signature/forms.py
  191. 3 2
      misago/apps/usercp/signature/urls.py
  192. 0 0
      misago/apps/usercp/signature/usercp.py
  193. 4 4
      misago/apps/usercp/signature/views.py
  194. 0 0
      misago/apps/usercp/template.py
  195. 1 1
      misago/apps/usercp/urls.py
  196. 0 0
      misago/apps/usercp/username/__init__.py
  197. 1 1
      misago/apps/usercp/username/forms.py
  198. 2 2
      misago/apps/usercp/username/urls.py
  199. 0 0
      misago/apps/usercp/username/usercp.py
  200. 6 8
      misago/apps/usercp/username/views.py
  201. 6 6
      misago/apps/usercp/views.py
  202. 0 0
      misago/apps/watchedthreads/__init__.py
  203. 8 0
      misago/apps/watchedthreads/urls.py
  204. 4 4
      misago/apps/watchedthreads/views.py
  205. 6 8
      misago/auth.py
  206. 0 20
      misago/authn/decorators.py
  207. 0 7
      misago/banning/context_processors.py
  208. 0 12
      misago/banning/decorators.py
  209. 0 4
      misago/banning/fixtures.py
  210. 0 40
      misago/banning/migrations/0001_initial.py
  211. 0 84
      misago/banning/models.py
  212. 0 7
      misago/bruteforce/context_processors.py
  213. 0 13
      misago/bruteforce/decorators.py
  214. 0 13
      misago/bruteforce/middleware.py
  215. 0 34
      misago/bruteforce/migrations/0001_initial.py
  216. 0 30
      misago/captcha/__init__.py
  217. 31 8
      misago/context_processors.py
  218. 0 0
      misago/cookiejar.py
  219. 17 1
      misago/crawlers.py
  220. 0 16
      misago/crawlers/database.py
  221. 0 9
      misago/crawlers/decorators.py
  222. 0 7
      misago/csrf/__init__.py
  223. 0 8
      misago/csrf/context_processors.py
  224. 0 10
      misago/csrf/decorators.py
  225. 0 13
      misago/csrf/middleware.py
  226. 5 2
      misago/dbsettings.py
  227. 74 0
      misago/decorators.py
  228. 5 6
      misago/firewalls.py
  229. 0 0
      misago/fixtures/__init__.py
  230. 15 8
      misago/fixtures/accountssetings.py
  231. 4 0
      misago/fixtures/aclmonitor.py
  232. 7 8
      misago/fixtures/avatarssettings.py
  233. 4 0
      misago/fixtures/bansmonitor.py
  234. 72 0
      misago/fixtures/basicsettings.py
  235. 7 8
      misago/fixtures/bruteforcesettings.py
  236. 8 10
      misago/fixtures/captchasettings.py
  237. 8 13
      misago/fixtures/forums.py
  238. 114 0
      misago/fixtures/forumsroles.py
  239. 27 0
      misago/fixtures/privatethreadssettings.py
  240. 7 53
      misago/fixtures/rankingsettings.py
  241. 48 0
      misago/fixtures/ranks.py
  242. 7 8
      misago/fixtures/signingsettings.py
  243. 8 9
      misago/fixtures/threadssettings.py
  244. 7 8
      misago/fixtures/tossettings.py
  245. 72 0
      misago/fixtures/userroles.py
  246. 14 0
      misago/fixtures/usersmonitor.py
  247. 4 176
      misago/forms/__init__.py
  248. 30 0
      misago/forms/fields.py
  249. 162 0
      misago/forms/forms.py
  250. 14 6
      misago/forms/layouts.py
  251. 8 0
      misago/forms/widgets.py
  252. 0 114
      misago/forumroles/fixtures.py
  253. 0 34
      misago/forumroles/migrations/0001_initial.py
  254. 0 235
      misago/forums/migrations/0001_initial.py
  255. 0 4
      misago/forums/signals.py
  256. 0 0
      misago/management/__init__.py
  257. 0 0
      misago/management/commands/__init__.py
  258. 2 2
      misago/management/commands/about.py
  259. 2 3
      misago/management/commands/adduser.py
  260. 1 1
      misago/management/commands/clearalerts.py
  261. 1 1
      misago/management/commands/clearattempts.py
  262. 1 1
      misago/management/commands/clearsessions.py
  263. 1 1
      misago/management/commands/cleartokens.py
  264. 3 3
      misago/management/commands/cleartracker.py
  265. 9 0
      misago/management/commands/forcepdssync.py
  266. 1 1
      misago/management/commands/genavatars.py
  267. 288 0
      misago/management/commands/migratefrom01.py
  268. 25 0
      misago/management/commands/pruneforums.py
  269. 32 0
      misago/management/commands/startmisago.py
  270. 1 1
      misago/management/commands/syncdeltas.py
  271. 52 0
      misago/management/commands/syncfixtures.py
  272. 2 2
      misago/management/commands/syncusermonitor.py
  273. 1 1
      misago/management/commands/updatemisago.py
  274. 4 5
      misago/management/commands/updateranking.py
  275. 3 3
      misago/management/commands/updatethreadranking.py
  276. 3 2
      misago/markdown/extensions/mentions.py
  277. 13 0
      misago/markdown/extensions/strikethrough.py
  278. 2 2
      misago/markdown/factory.py
  279. 0 0
      misago/messages.py
  280. 0 7
      misago/messages/context_processors.py
  281. 0 0
      misago/middleware/__init__.py
  282. 3 2
      misago/middleware/acl.py
  283. 3 2
      misago/middleware/banning.py
  284. 31 0
      misago/middleware/bruteforce.py
  285. 3 3
      misago/middleware/cookiejar.py
  286. 2 2
      misago/middleware/crawlers.py
  287. 23 0
      misago/middleware/csrf.py
  288. 2 2
      misago/middleware/firewalls.py
  289. 0 0
      misago/middleware/heartbeat.py
  290. 0 0
      misago/middleware/messages.py
  291. 1 1
      misago/middleware/monitor.py
  292. 13 0
      misago/middleware/privatethreads.py
  293. 3 3
      misago/middleware/session.py
  294. 5 0
      misago/middleware/settings.py
  295. 0 0
      misago/middleware/stopwatch.py
  296. 2 2
      misago/middleware/theme.py
  297. 0 0
      misago/middleware/user.py
  298. 951 0
      misago/migrations/0001_initial.py
  299. 0 0
      misago/migrations/__init__.py
  300. 26 0
      misago/models/__init__.py
  301. 8 5
      misago/models/alertmodel.py
  302. 90 0
      misago/models/banmodel.py
  303. 61 0
      misago/models/changemodel.py
  304. 67 0
      misago/models/checkpointmodel.py
  305. 7 0
      misago/models/fixturemodel.py
  306. 52 23
      misago/models/forummodel.py
  307. 29 0
      misago/models/forumreadmodel.py
  308. 10 9
      misago/models/forumrolemodel.py
  309. 60 0
      misago/models/karmamodel.py
  310. 4 1
      misago/models/monitoritemmodel.py
  311. 7 4
      misago/models/newslettermodel.py
  312. 156 0
      misago/models/postmodel.py
  313. 13 13
      misago/models/pruningpolicymodel.py
  314. 6 1
      misago/models/rankmodel.py
  315. 18 12
      misago/models/rolemodel.py
  316. 4 7
      misago/models/sessionmodel.py
  317. 26 34
      misago/models/settingmodel.py
  318. 15 0
      misago/models/settingsgroupmodel.py
  319. 6 22
      misago/models/signinattemptmodel.py
  320. 3 3
      misago/models/themeadjustmentmodel.py
  321. 208 0
      misago/models/threadmodel.py
  322. 23 0
      misago/models/threadreadmodel.py
  323. 10 0
      misago/models/tokenmodel.py
  324. 37 17
      misago/models/usermodel.py
  325. 4 1
      misago/models/usernamechangemodel.py
  326. 35 0
      misago/models/watchedthreadmodel.py
  327. 3 4
      misago/monitor.py
  328. 0 7
      misago/monitor/context_processors.py
  329. 0 11
      misago/monitor/fixtures.py
  330. 0 34
      misago/monitor/migrations/0001_initial.py
  331. 0 70
      misago/newsletters/migrations/0001_initial.py
  332. 0 40
      misago/prune/migrations/0001_initial.py
  333. 0 50
      misago/ranks/migrations/0001_initial.py
  334. 0 217
      misago/readstracker/migrations/0001_initial.py
  335. 0 234
      misago/readstracker/migrations/0002_auto__add_threadrecord__del_field_record_threads.py
  336. 0 221
      misago/readstracker/migrations/0003_auto__del_record__add_forumrecord.py
  337. 0 0
      misago/readstracker/migrations/__init__.py
  338. 0 37
      misago/readstracker/models.py
  339. 26 19
      misago/readstrackers.py
  340. 0 0
      misago/register/__init__.py
  341. 0 5
      misago/register/urls.py
  342. 0 0
      misago/resetpswd/__init__.py
  343. 0 0
      misago/roles/__init__.py
  344. 0 50
      misago/roles/fixtures.py
  345. 0 38
      misago/roles/migrations/0001_initial.py
  346. 0 0
      misago/roles/migrations/__init__.py
  347. 31 26
      misago/sessions.py
  348. 0 0
      misago/sessions/__init__.py
  349. 0 0
      misago/sessions/management/__init__.py
  350. 0 0
      misago/sessions/management/commands/__init__.py
  351. 0 154
      misago/sessions/migrations/0001_initial.py
  352. 0 0
      misago/sessions/migrations/__init__.py
  353. 0 0
      misago/settings/__init__.py
  354. 0 7
      misago/settings/context_processors.py
  355. 0 146
      misago/settings/fixtures.py
  356. 0 5
      misago/settings/middleware.py
  357. 0 69
      misago/settings/migrations/0001_initial.py
  358. 0 0
      misago/settings/migrations/__init__.py
  359. 36 76
      misago/settings_base.py
  360. 0 0
      misago/setup/__init__.py
  361. 0 34
      misago/setup/fixtures.py
  362. 0 0
      misago/setup/management/__init__.py
  363. 0 0
      misago/setup/management/commands/__init__.py
  364. 0 47
      misago/setup/management/commands/initdata.py
  365. 0 15
      misago/setup/management/commands/initmisago.py
  366. 0 32
      misago/setup/migrations/0001_initial.py
  367. 0 0
      misago/setup/migrations/__init__.py
  368. 0 4
      misago/setup/models.py
  369. 7 3
      misago/signals.py
  370. 0 0
      misago/stats/__init__.py
  371. 0 0
      misago/stopwatch.py
  372. 0 0
      misago/team/__init__.py
  373. 0 0
      misago/template/__init__.py
  374. 0 0
      misago/template/templatetags/__init__.py
  375. 0 0
      misago/templatetags/__init__.py
  376. 19 0
      misago/templatetags/datetime.py
  377. 34 0
      misago/templatetags/django2jinja.py
  378. 38 0
      misago/templatetags/markdown.py
  379. 17 0
      misago/templatetags/utils.py
  380. 1 0
      misago/tests/__init__.py
  381. 56 0
      misago/tests/testuseradd.py
  382. 15 6
      misago/theme.py
  383. 0 0
      misago/themes/__init__.py
  384. 0 34
      misago/themes/migrations/0001_initial.py
  385. 0 0
      misago/themes/migrations/__init__.py
  386. 0 0
      misago/threads/__init__.py
  387. 0 226
      misago/threads/forms.py
  388. 0 0
      misago/threads/management/__init__.py
  389. 0 0
      misago/threads/management/commands/__init__.py
  390. 0 382
      misago/threads/migrations/0001_initial.py
  391. 0 0
      misago/threads/migrations/__init__.py
  392. 0 392
      misago/threads/models.py
  393. 0 161
      misago/threads/tests.py
  394. 0 40
      misago/threads/testutils.py
  395. 0 34
      misago/threads/urls.py
  396. 0 9
      misago/threads/views/__init__.py
  397. 0 14
      misago/threads/views/base.py
  398. 0 106
      misago/threads/views/delete.py
  399. 0 41
      misago/threads/views/details.py
  400. 0 43
      misago/threads/views/karmas.py
  401. 0 395
      misago/threads/views/posting.py
  402. 0 566
      misago/threads/views/thread.py
  403. 0 0
      misago/tos/__init__.py
  404. 29 23
      misago/urls.py
  405. 0 0
      misago/usercp/__init__.py
  406. 0 0
      misago/usercp/avatar/__init__.py
  407. 0 0
      misago/usercp/credentials/__init__.py
  408. 0 117
      misago/usercp/migrations/0001_initial.py
  409. 0 0
      misago/usercp/migrations/__init__.py
  410. 0 0
      misago/usercp/options/__init__.py
  411. 0 42
      misago/usercp/options/forms.py
  412. 0 0
      misago/usercp/signature/__init__.py
  413. 0 0
      misago/usercp/username/__init__.py
  414. 0 0
      misago/users/__init__.py
  415. 0 7
      misago/users/context_processors.py
  416. 0 13
      misago/users/fixtures.py
  417. 0 0
      misago/users/management/__init__.py
  418. 0 0
      misago/users/management/commands/__init__.py
  419. 0 192
      misago/users/migrations/0001_initial.py
  420. 0 0
      misago/users/migrations/__init__.py
  421. 0 4
      misago/users/signals.py
  422. 0 94
      misago/utils/__init__.py
  423. 27 86
      misago/utils/datesformats.py
  424. 109 0
      misago/utils/fixtures.py
  425. 30 0
      misago/utils/pagination.py
  426. 31 0
      misago/utils/strings.py
  427. 0 0
      misago/utils/timezones.py
  428. 20 0
      misago/utils/translation.py
  429. 0 15
      misago/utils/validators.py
  430. 22 0
      misago/utils/views.py
  431. 23 4
      misago/validators.py
  432. 0 203
      misago/views.py
  433. 0 0
      misago/watcher/__init__.py
  434. 0 217
      misago/watcher/migrations/0001_initial.py
  435. 0 0
      misago/watcher/migrations/__init__.py
  436. 0 33
      misago/watcher/models.py
  437. 0 8
      misago/watcher/urls.py
  438. 12 0
      requirements.txt
  439. 64 38
      static/cranefly/css/cranefly.css
  440. 36 10
      static/cranefly/css/cranefly/category.less
  441. 35 9
      static/cranefly/css/cranefly/forum.less
  442. 48 1
      static/cranefly/css/cranefly/header.less
  443. 36 10
      static/cranefly/css/cranefly/index.less
  444. 0 10
      static/cranefly/css/cranefly/karmas.less
  445. 4 3
      static/cranefly/css/cranefly/markdown.less
  446. 84 2
      static/cranefly/css/cranefly/navbar.less
  447. 121 11
      static/cranefly/css/cranefly/thread.less
  448. 6 6
      static/cranefly/css/ranks.less
  449. 50 1
      static/cranefly/js/cranefly.js
  450. 0 0
      templates/_email/base.html
  451. 0 0
      templates/_email/base.txt
  452. 9 0
      templates/_email/private_thread_invite.html
  453. 10 0
      templates/_email/private_thread_invite.txt
  454. 9 0
      templates/_email/private_thread_reply_notification.html
  455. 10 0
      templates/_email/private_thread_reply_notification.txt
  456. 1 1
      templates/_email/thread_reply_notification.html
  457. 1 1
      templates/_email/thread_reply_notification.txt
  458. 1 1
      templates/_email/users/activation/admin.html
  459. 1 1
      templates/_email/users/activation/admin.txt
  460. 1 1
      templates/_email/users/activation/admin_done.html
  461. 1 1
      templates/_email/users/activation/admin_done.txt
  462. 1 1
      templates/_email/users/activation/invalidated.html
  463. 1 1
      templates/_email/users/activation/invalidated.txt
  464. 1 1
      templates/_email/users/activation/none.html
  465. 1 1
      templates/_email/users/activation/none.txt
  466. 1 1
      templates/_email/users/activation/resend.html
  467. 1 1
      templates/_email/users/activation/resend.txt
  468. 1 1
      templates/_email/users/activation/user.html
  469. 1 1
      templates/_email/users/activation/user.txt
  470. 1 1
      templates/_email/users/new_credentials.html
  471. 1 1
      templates/_email/users/new_credentials.txt
  472. 1 1
      templates/_email/users/newsletter.html
  473. 1 1
      templates/_email/users/newsletter.txt
  474. 1 1
      templates/_email/users/password/confirm.html
  475. 1 1
      templates/_email/users/password/confirm.txt
  476. 1 1
      templates/_email/users/password/new.html
  477. 1 1
      templates/_email/users/password/new.txt
  478. 1 1
      templates/_email/users/password/new_admin.html
  479. 1 1
      templates/_email/users/password/new_admin.txt
  480. 3 8
      templates/admin/index.html
  481. 0 2
      templates/admin/layout_compact.html
  482. 0 2
      templates/admin/signin.html
  483. 1 1
      templates/cranefly/base.html
  484. 41 42
      templates/cranefly/category.html
  485. 42 43
      templates/cranefly/index.html
  486. 26 3
      templates/cranefly/layout.html
  487. 5 1
      templates/cranefly/macros.html
  488. 2 2
      templates/cranefly/new_threads.html
  489. 2 2
      templates/cranefly/newsfeed.html
  490. 2 2
      templates/cranefly/popular_threads.html
  491. 81 0
      templates/cranefly/private_threads/changelog.html
  492. 95 0
      templates/cranefly/private_threads/changelog_diff.html
  493. 35 0
      templates/cranefly/private_threads/details.html
  494. 184 0
      templates/cranefly/private_threads/list.html
  495. 174 0
      templates/cranefly/private_threads/posting.html
  496. 541 0
      templates/cranefly/private_threads/thread.html
  497. 5 5
      templates/cranefly/profiles/list.html
  498. 2 2
      templates/cranefly/profiles/posts.html
  499. 9 0
      templates/cranefly/profiles/profile.html
  500. 1 1
      templates/cranefly/profiles/threads.html
  501. 3 3
      templates/cranefly/threads/changelog.html
  502. 4 4
      templates/cranefly/threads/changelog_diff.html
  503. 21 2
      templates/cranefly/threads/karmas.html
  504. 43 19
      templates/cranefly/threads/list.html
  505. 0 27
      templates/cranefly/threads/merge.html
  506. 32 46
      templates/cranefly/threads/posting.html
  507. 51 46
      templates/cranefly/threads/thread.html
  508. 2 2
      templates/cranefly/watched.html
  509. 3 3
      templates/debug_toolbar/base.html
  510. 51 38
      templates/debug_toolbar/panels/cache.html
  511. 1 1
      templates/debug_toolbar/panels/logger.html
  512. 13 6
      templates/debug_toolbar/panels/profiling.html
  513. 2 0
      templates/debug_toolbar/panels/request_vars.html
  514. 3 3
      templates/debug_toolbar/panels/settings_vars.html
  515. 1 1
      templates/debug_toolbar/panels/signals.html
  516. 11 10
      templates/debug_toolbar/panels/sql.html
  517. 2 0
      templates/debug_toolbar/panels/sql_explain.html
  518. 2 0
      templates/debug_toolbar/panels/sql_profile.html
  519. 2 0
      templates/debug_toolbar/panels/sql_select.html
  520. 4 4
      templates/debug_toolbar/panels/templates.html
  521. 1 2
      templates/debug_toolbar/panels/versions.html
  522. 4 1
      templates/debug_toolbar/redirect.html

+ 1 - 0
.gitignore

@@ -127,6 +127,7 @@ Thumbs.db
 
 # Folder config file
 Desktop.ini
+Github for Windows Log.lnk
 
 
 #############

+ 7 - 0
.travis.yml

@@ -0,0 +1,7 @@
+language: python
+python:
+  - "2.7"
+# command to install dependencies
+install: "pip install -r requirements.txt --use-mirrors"
+# command to run tests
+script: python manage.py test

+ 21 - 15
README.md

@@ -1,18 +1,18 @@
 Misago
 ======
 
-Misago is an internet forum application written in Python and using Django as its foundation.
+Misago is internet forum application written in Python and using Django as its foundation.
 Visit project homepage for discussion and demo: <http://misago-project.org>
 
 
 The Tao AKA Mission Statement
 -----------------------------
 
-I want software focused on enabling smooth flow of information between forum members. I don't want to build a "Facebook CMS" that contains lots of extra functionality like user galleries, blogs or user walls. Posting and replying in threads is the only focus of Misago with additional features implemented to improve forum users and staff experience.
+I want software focused on enabling smooth flow of information between forum members. I dont want to build "Facebook CMS" that contains lots of extra functionality like user galleries, blogs or user walls. Posting and replying in threads is only focus of Misago with additional features implemented to improve forum users and staff experience.
 
-Secondary goal is making Misago a viable foundation for building and maintaining long-term discussion forums for administrators. Misago trades "casual admin" friendliness for advanced features aimed for use by web developers looking for a tool to build forums for their site.
+Secondary goal is making Misago a viable foundation for building and maintaining long-term discussion forums for administrators. Misago trades "casual admin" friendlyness for advanced features aimed for use by web developers looking for tool to build forums for their site.
 
-Finally while Misago is built using Django, it's not a "Django application" and it won't integrate with existing Django projects. This is the result of design decision to use custom users/session/auth/permissions functionality instead of Django native applications - however in the future Misago will provide a web API allowing you to add Misago-powered features to your website.
+Finally while Misago is build using Django, its not "Django application" and it wont integrate with existing Django projects. This is result of design decision to use custom users/session/auth/permissions functionality instead of Django native applications - however in future Misago will provide web API allowing you to add Misago-powered features to your website.
 
 
 Dependencies
@@ -27,7 +27,7 @@ Dependencies
 * [path](http://pypi.python.org/pypi/path.py)
 * [Pillow](http://pypi.python.org/pypi/Pillow/)
 * [pyTZ](http://pypi.python.org/pypi/pytz/2012h)
-* [reCAPTCHA-client](http://pypi.python.org/pypi/recaptcha-client)
+* [reCAPTCHA](http://pypi.python.org/pypi/recaptcha-client)
 * [South](http://south.aeracode.org)
 * [Unidecode](http://pypi.python.org/pypi/Unidecode)
 
@@ -35,33 +35,39 @@ Dependencies
 Installation
 ------------
 
-The very first thing that needs to be done is ensure you have all the dependencies installed. Most can be installed through `pip`.
+Misago comes with "deployment" python module that contains empty Misago configuration and default Django WSGI container for you to use in your deployments.
 
-Misago comes with the "deployment" Python module that contains empty Misago configuration and default Django WSGI container for you to use in your deployments.
+After you set low-level configuration of Misago, fire following commands on manage.py:
 
-After you set low-level configuration of Misago (`settings/settings.py`), fire the following commands on manage.py through the Python executable:
-
-* __initmisago__ - creates DB structure for Misago and populates it with default data
-* __adduser__ Admin admin@example.com password --admin__ - this will create first admin user
+* __startmisago__ - creates DB structure for Misago and populates it with default data
+* __adduser Admin admin@example.com password --admin__ - this will create first admin user
 
 Misago stands on shoulders of Django and Django documentation covers deployment of apps extensively:
 https://docs.djangoproject.com/en/dev/howto/deployment/
 
 Don't forget to set up maintenance cronjobs to keep your database clean, look into cront.txt file to see what crons to set up.
 
-While Misago will run without cache set up, you are strongly encouraged to set one up for it. Even if you choose not to run one, you will still need to set a default one (such as dummy caching).
+While Misago will run without cache set up, you are stronly encouraged to set one up for it.
 
 ### WARNING!
 
 Misago is not production ready! Don't ever use it in anything thats anywhere close to production enviroment!
 
 
+Updating
+--------
+
+You can use **updatemisago** command to update your forums database to latest version *unless* you are updating from 0.1 which is incompatibile with 0.2 and later releases.
+
+If you want to move data from 0.1 to 0.2, install 0.2 to new database, then add connection to 0.1 database in your settings.py and name it "deprecated". When you are ready, use migratefrom01 management command to move data from 0.1 database over to 0.2.
+
+
 Contributing
 ------------
 
-Misago is open source project. You are free to submit pull requests against master branch, and use issues system to report bugs, propose improvements and new features.
+Misago is open source project. You are free to submit pull requests against master branch, but I request all interested in contributing to project to register on project forums and participate in [development discussion](http://misago-project.org/category/development-discussion-17/).
 
-Finally, you can participate in discussion on [project forums](http://misago-project.org). Your feedback means much more for the project, don't keep it to yourself!
+Finally, you can participate in discussion on [project forums](http://misago-project.org). Your feedback means much for project, don't keep it to yourself!
 
 
 Authors
@@ -82,4 +88,4 @@ This program comes with ABSOLUTELY NO WARRANTY.
 This is free software, and you are welcome to redistribute it
 under certain conditions.
 
-For the complete license, see LICENSE.txt
+For complete license, see LICENSE.txt

+ 98 - 0
README.md.orig

@@ -0,0 +1,98 @@
+Misago
+======
+
+Misago is an internet forum application written in Python and using Django as its foundation.
+Visit project homepage for discussion and demo: <http://misago-project.org>
+
+
+The Tao AKA Mission Statement
+-----------------------------
+
+I want software focused on enabling smooth flow of information between forum members. I don't want to build a "Facebook CMS" that contains lots of extra functionality like user galleries, blogs or user walls. Posting and replying in threads is the only focus of Misago with additional features implemented to improve forum users and staff experience.
+
+Secondary goal is making Misago a viable foundation for building and maintaining long-term discussion forums for administrators. Misago trades "casual admin" friendliness for advanced features aimed for use by web developers looking for a tool to build forums for their site.
+
+Finally while Misago is built using Django, it's not a "Django application" and it won't integrate with existing Django projects. This is the result of design decision to use custom users/session/auth/permissions functionality instead of Django native applications - however in the future Misago will provide a web API allowing you to add Misago-powered features to your website.
+
+
+Dependencies
+------------
+
+* [Django](http://djangoproject.com)
+* [Django Debug Toolbar](https://github.com/django-debug-toolbar/django-debug-toolbar)
+* [Django-MPTT](https://github.com/django-mptt/django-mptt)
+* [Coffin](https://github.com/coffin/coffin)
+* [Jinja2](https://github.com/mitsuhiko/jinja2)
+* [Markdown](http://pypi.python.org/pypi/Markdown)
+* [path](http://pypi.python.org/pypi/path.py)
+* [Pillow](http://pypi.python.org/pypi/Pillow/)
+* [pyTZ](http://pypi.python.org/pypi/pytz/2012h)
+* [reCAPTCHA-client](http://pypi.python.org/pypi/recaptcha-client)
+* [South](http://south.aeracode.org)
+* [Unidecode](http://pypi.python.org/pypi/Unidecode)
+
+
+Installation
+------------
+
+The very first thing that needs to be done is ensure you have all the dependencies installed. Most can be installed through `pip`.
+
+Misago comes with the "deployment" Python module that contains empty Misago configuration and default Django WSGI container for you to use in your deployments.
+
+After you set low-level configuration of Misago (`settings/settings.py`), fire the following commands on manage.py through the Python executable:
+
+<<<<<<< HEAD
+* __initmisago__ - creates DB structure for Misago and populates it with default data
+* __adduser__ Admin admin@example.com password --admin__ - this will create first admin user
+=======
+* __startmisago__ - creates DB structure for Misago and populates it with default data
+* __adduser Admin admin@example.com password --admin__ - this will create first admin user
+>>>>>>> develop
+
+Misago stands on shoulders of Django and Django documentation covers deployment of apps extensively:
+https://docs.djangoproject.com/en/dev/howto/deployment/
+
+Don't forget to set up maintenance cronjobs to keep your database clean, look into cront.txt file to see what crons to set up.
+
+While Misago will run without cache set up, you are strongly encouraged to set one up for it. Even if you choose not to run one, you will still need to set a default one (such as dummy caching).
+
+### WARNING!
+
+Misago is not production ready! Don't ever use it in anything thats anywhere close to production enviroment!
+
+
+Updating
+--------
+
+You can use **updatemisago** command to update your forums database to latest version *unless* you are updating from 0.1 which is incompatibile with 0.2 and later releases.
+
+If you want to move data from 0.1 to 0.2, install 0.2 to new database, then add connection to 0.1 database in your settings.py and name it "deprecated". When you are ready, use migratefrom01 management command to move data from 0.1 database over to 0.2.
+
+
+Contributing
+------------
+
+Misago is open source project. You are free to submit pull requests against master branch, but I request all interested in contributing to project to register on project forums and participate in [development discussion](http://misago-project.org/category/development-discussion-17/).
+
+Finally, you can participate in discussion on [project forums](http://misago-project.org). Your feedback means much more for the project, don't keep it to yourself!
+
+
+Authors
+-------
+
+**Rafał Pitoń**
+
++ http://rpiton.com
++ http://github.com/ralfp
++ https://twitter.com/RafalPiton
+
+
+Copyright and license
+---------------------
+
+Misago  Copyright (C) 2012  Rafał Pitoń
+This program comes with ABSOLUTELY NO WARRANTY.
+This is free software, and you are welcome to redistribute it
+under certain conditions.
+
+For the complete license, see LICENSE.txt

+ 6 - 3
cron.txt

@@ -1,7 +1,10 @@
-0 */4 * * * python $HOME/misago/manage.py clearsessions
 0 3 * * * python $HOME/misago/manage.py clearalerts
+0 3 * * * python $HOME/misago/manage.py clearattempts
+0 */4 * * * python $HOME/misago/manage.py clearsessions
+20 3 * * * python $HOME/misago/manage.py cleartokens
+15 3 * * * python $HOME/misago/manage.py cleartracker
+0 0 * * 0 python $HOME/misago/manage.py forcepdssync
+0 3 */2 * * python $HOME/misago/manage.py pruneforums
 5 3 * * * python $HOME/misago/manage.py syncdeltas
 10 3 * * * python $HOME/misago/manage.py updateranking
-15 3 * * * python $HOME/misago/manage.py cleartracker
-20 3 * * * python $HOME/misago/manage.py cleartokens
 25 3 * * * python $HOME/misago/manage.py updatethreadranking

+ 11 - 1
deployment/settings.py

@@ -1,3 +1,4 @@
+import sys
 from misago.settings_base import *
 
 # Allow debug?
@@ -29,11 +30,20 @@ DATABASES = {
     }
 }
 
+# Override DB if we are in tests
+if 'test' in sys.argv:
+    DATABASES['default'] = {'ENGINE': 'django.db.backends.sqlite3'}
+    SKIP_SOUTH_TESTS = True
+
 # Cache engine
 # Misago is EXTREMELY data hungry
 # If you don't set any cache, it will BRUTALISE your database and memory
 # In production ALWAYS use cache
-CACHES = {}
+CACHES = {
+    'default': {
+        'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
+   }
+}
 
 # Cookies configuration
 COOKIES_DOMAIN = '' # For example cookie domain for "www.mysite.com" or "forum.mysite.com" is ".mysite.com"

+ 1 - 23
misago/__init__.py

@@ -1,23 +1 @@
-VERSION = (0, 1, 0, 'alpha', 0)
-
-def get_version(version=None):
-    """Derives a PEP386-compliant version number from VERSION."""
-    if version is None:
-        version = VERSION
-    assert len(version) == 5
-    assert version[3] in ('alpha', 'beta', 'rc', 'final')
-
-    # Now build the two parts of the version number:
-    # main = X.Y[.Z]
-    # sub = .devN - for pre-alpha releases
-    #     | {a|b|c}N - for alpha, beta and rc releases
-
-    parts = 2 if version[2] == 0 else 3
-    main = '.'.join(str(x) for x in version[:parts])
-
-    sub = ''
-    if version[3] != 'final':
-        mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'}
-        sub = mapping[version[3]] + str(version[4])
-
-    return main + sub
+__version__ = "0.2.0 DEV"

+ 6 - 6
misago/acl/builder.py

@@ -2,8 +2,7 @@ from django.conf import settings
 from django.core.cache import cache, InvalidCacheBackendError
 from django.utils.importlib import import_module
 from misago.forms import Form
-from misago.forums.models import Forum
-from misago.forumroles.models import ForumRole
+from misago.models import Forum, ForumRole
 
 def build_form(request, role):
     form_type = type('ACLForm', (Form,), dict(layout=[]))
@@ -47,7 +46,7 @@ class ACL(object):
                 yield self.__dict__[attr]
 
 
-def get_acl(request, user):
+def acl(request, user):
     acl_key = user.make_acl_key()
     try:
         user_acl = cache.get(acl_key)
@@ -61,15 +60,16 @@ def get_acl(request, user):
 
 def build_acl(request, roles):
     acl = ACL(request.monitor['acl_version'])
-    forums = Forum.objects.get(token='root').get_descendants().order_by('lft')
+    forums = (Forum.objects.filter(special__in=('private_threads', 'reports'))
+              | Forum.objects.get(special='root').get_descendants().order_by('lft'))
     perms = []
     forum_roles = {}
 
     for role in roles:
-        perms.append(role.get_permissions())
+        perms.append(role.permissions)
 
     for role in ForumRole.objects.all():
-        forum_roles[role.pk] = role.get_permissions()
+        forum_roles[role.pk] = role.permissions
 
     for provider in settings.PERMISSION_PROVIDERS:
         app_module = import_module(provider)

+ 0 - 7
misago/acl/context_processors.py

@@ -1,7 +0,0 @@
-def acl(request):
-    try:
-        return {
-            'acl': request.acl,
-        }
-    except AttributeError:
-        pass

+ 9 - 0
misago/acl/exceptions.py

@@ -0,0 +1,9 @@
+"""
+ACL Exceptions thrown by Misago actions
+"""
+
+class ACLError403(Exception):
+    pass
+
+class ACLError404(Exception):
+    pass

+ 0 - 9
misago/acl/fixtures.py

@@ -1,9 +0,0 @@
-from misago.monitor.fixtures import load_monitor_fixture
-
-monitor_fixtures = {
-                  'acl_version': 0,
-                  }
-
-
-def load_fixtures():
-    load_monitor_fixture(monitor_fixtures)

+ 0 - 0
misago/activation/__init__.py → misago/acl/permissions/__init__.py


+ 1 - 1
misago/forums/acl.py → misago/acl/permissions/forums.py

@@ -1,7 +1,7 @@
 from django.utils.translation import ugettext_lazy as _
 from django import forms
 from misago.acl.builder import BaseACL
-from misago.acl.utils import ACLError403, ACLError404
+from misago.acl.exceptions import ACLError403, ACLError404
 from misago.forms import YesNoSwitch
 
 def make_forum_form(request, role, form):

+ 124 - 0
misago/acl/permissions/privatethreads.py

@@ -0,0 +1,124 @@
+from django.utils.translation import ugettext_lazy as _
+from django import forms
+from misago.acl.builder import BaseACL
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.forms import YesNoSwitch
+from misago.models import Forum
+
+def make_form(request, role, form):
+    if role.special != 'guest':
+        form.base_fields['can_use_private_threads'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+        form.base_fields['can_start_private_threads'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+        form.base_fields['can_upload_attachments_in_private_threads'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+        form.base_fields['private_thread_attachment_size'] = forms.IntegerField(min_value=0, initial=100, required=False)
+        form.base_fields['private_thread_attachments_limit'] = forms.IntegerField(min_value=0, initial=3, required=False)
+        form.base_fields['can_invite_ignoring'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+        form.base_fields['private_threads_mod'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+        form.layout.append((
+                            _("Private Threads"),
+                            (
+                             ('can_use_private_threads', {'label': _("Can participate in private threads")}),
+                             ('can_start_private_threads', {'label': _("Can start private threads")}),
+                             ('can_upload_attachments_in_private_threads', {'label': _("Can upload files in attachments")}),
+                             ('private_thread_attachment_size', {'label': _("Max. size of single attachment (in KB)")}),
+                             ('private_thread_attachments_limit', {'label': _("Max. number of attachments per post")}),
+                             ('can_invite_ignoring', {'label': _("Can invite users that ignore him")}),
+                             ('private_threads_mod', {'label': _("Can moderate threads"), 'help_text': _("Makes user with this role Private Threads moderator capable of closing, deleting and editing all private threads he participates in at will.")}),
+                             ),
+                            ))
+
+
+class PrivateThreadsACL(BaseACL):
+    def can_start(self):
+        return (self.acl['can_use_private_threads'] and
+                self.acl['can_start_private_threads'])
+
+    def can_participate(self):
+        return self.acl['can_use_private_threads']
+        
+    def can_invite_ignoring(self):
+        return self.acl['can_invite_ignoring']
+        
+    def is_mod(self):
+        return self.acl['private_threads_mod']
+
+
+def build(acl, roles):
+    acl.private_threads = PrivateThreadsACL()
+    acl.private_threads.acl['can_use_private_threads'] = False
+    acl.private_threads.acl['can_start_private_threads'] = False
+    acl.private_threads.acl['can_upload_attachments_in_private_threads'] = False
+    acl.private_threads.acl['private_thread_attachment_size'] = False
+    acl.private_threads.acl['private_thread_attachments_limit'] = False
+    acl.private_threads.acl['can_invite_ignoring'] = False
+    acl.private_threads.acl['private_threads_mod'] = False
+
+    for role in roles:
+        for perm, value in acl.private_threads.acl.items():
+            if perm in role and role[perm] > value:
+                acl.private_threads.acl[perm] = role[perm]
+
+
+def cleanup(acl, perms, forums):
+    forum = Forum.objects.special_pk('private_threads')
+    acl.threads.acl[forum] = {
+                              'can_read_threads': 2,
+                              'can_start_threads': 0,
+                              'can_edit_own_threads': True,
+                              'can_soft_delete_own_threads': False,
+                              'can_write_posts': 2,
+                              'can_edit_own_posts': True,
+                              'can_soft_delete_own_posts': True,
+                              'can_upvote_posts': False,
+                              'can_downvote_posts': False,
+                              'can_see_posts_scores': 0,
+                              'can_see_votes': False,
+                              'can_make_polls': False,
+                              'can_vote_in_polls': False,
+                              'can_see_poll_votes': False,
+                              'can_see_attachments': True,
+                              'can_upload_attachments': False,
+                              'can_download_attachments': True,
+                              'attachment_size': 100,
+                              'attachment_limit': 3,
+                              'can_approve': False,
+                              'can_edit_labels': False,
+                              'can_see_changelog': False,
+                              'can_pin_threads': 0,
+                              'can_edit_threads_posts': False,
+                              'can_move_threads_posts': False,
+                              'can_close_threads': False,
+                              'can_protect_posts': False,
+                              'can_delete_threads': 0,
+                              'can_delete_posts': 0,
+                              'can_delete_polls': 0,
+                              'can_delete_attachments': False,
+                              'can_invite_ignoring': False,
+                             }
+
+    for perm in perms:
+        try:
+            if perm['can_use_private_threads']:
+                acl.forums.acl['can_see'].append(forum)
+                acl.forums.acl['can_browse'].append(forum)
+            if perm['can_start_private_threads']:
+                acl.threads.acl[forum]['can_start_threads'] = 2
+            if perm['can_upload_attachments_in_private_threads']:
+                acl.threads.acl[forum]['can_upload_attachments'] = True
+            if perm['private_thread_attachment_size']:
+                acl.threads.acl[forum]['attachment_size'] = True
+            if perm['private_thread_attachments_limit']:
+                acl.threads.acl[forum]['attachment_limit'] = True
+            if perm['can_invite_ignoring']:
+                acl.threads.acl[forum]['can_invite_ignoring'] = True
+            if perm['private_threads_mod']:
+                acl.threads.acl[forum]['can_close_threads'] = True
+                acl.threads.acl[forum]['can_protect_posts'] = True
+                acl.threads.acl[forum]['can_edit_threads_posts'] = True
+                acl.threads.acl[forum]['can_move_threads_posts'] = True
+                acl.threads.acl[forum]['can_see_changelog'] = True
+                acl.threads.acl[forum]['can_delete_threads'] = 2
+                acl.threads.acl[forum]['can_delete_posts'] = 2
+                acl.threads.acl[forum]['can_delete_attachments'] = True
+        except KeyError:
+            pass

+ 41 - 0
misago/acl/permissions/special.py

@@ -0,0 +1,41 @@
+from django.utils.translation import ugettext_lazy as _
+from django import forms
+from misago.acl.builder import BaseACL
+from misago.forms import YesNoSwitch
+
+def make_form(request, role, form):
+    if not role.special and request.user.is_god():
+        form.base_fields['can_use_mcp'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+        form.base_fields['can_use_acp'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+        form.layout.append((_("Special Access"),
+                            (
+                             ('can_use_mcp', {'label': _("Can use Moderator Control Panel"), 'help_text': _("Change this permission to yes to grant access to Mod CP for users with this role.")}),
+                             ('can_use_acp', {'label': _("Can use Admin Control Panel"), 'help_text': _("Change this permission to yes to grant admin access for users with this role.")}),
+                             )
+                            ))
+
+
+class SpecialACL(BaseACL):
+    def is_admin(self):
+        return self.acl['can_use_acp']
+
+    def can_use_mcp(self):
+        return self.acl['can_use_mcp']
+
+
+def build(acl, roles):
+    acl.special = SpecialACL()
+    acl.special.acl['can_use_acp'] = False
+    acl.special.acl['can_use_mcp'] = False
+
+    for role in roles:
+        try:
+            if role['can_use_acp']:
+                acl.special.acl['can_use_acp'] = True
+            if 'can_use_mcp' in role and role['can_use_mcp']:
+                acl.special.acl['can_use_mcp'] = True
+        except KeyError:
+            pass
+
+    if acl.special.acl['can_use_acp'] or acl.special.acl['can_use_mcp']:
+        acl.team = True

+ 23 - 11
misago/threads/acl.py → misago/acl/permissions/threads.py

@@ -3,7 +3,7 @@ from django.db import models
 from django.db.models import Q
 from django.utils.translation import ugettext_lazy as _
 from misago.acl.builder import BaseACL
-from misago.acl.utils import ACLError403, ACLError404
+from misago.acl.exceptions import ACLError403, ACLError404
 from misago.forms import YesNoSwitch
 
 def make_forum_form(request, role, form):
@@ -48,7 +48,7 @@ def make_forum_form(request, role, form):
     form.base_fields['can_pin_threads'] = forms.TypedChoiceField(choices=(
                                                                  (0, _("No")),
                                                                  (1, _("Yes, to stickies")),
-                                                                 (2, _("Yes, to annoucements")),
+                                                                 (2, _("Yes, to announcements")),
                                                                  ), coerce=int)
     form.base_fields['can_edit_threads_posts'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
     form.base_fields['can_move_threads_posts'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
@@ -121,7 +121,6 @@ def make_forum_form(request, role, form):
                          ('can_approve', {'label': _("Can accept threads and posts")}),
                          ('can_edit_labels', {'label': _("Can edit thread labels")}),
                          ('can_see_changelog', {'label': _("Can see edits history")}),
-                         ('can_make_annoucements', {'label': _("Can make annoucements")}),
                          ('can_pin_threads', {'label': _("Can change threads weight")}),
                          ('can_edit_threads_posts', {'label': _("Can edit threads and posts")}),
                          ('can_move_threads_posts', {'label': _("Can move, merge and split threads and posts")}),
@@ -166,13 +165,6 @@ class ThreadsACL(BaseACL):
         if post.deleted and not (forum_role['can_delete_posts'] or (user.is_authenticated() and user == post.user)):
             raise ACLError404()
 
-    def get_readable_forums(self, acl):
-        readable = []
-        for forum in self.acl:
-            if acl.forums.can_browse(forum) and self.acl[forum]['can_read_threads']:
-                readable.append(forum)
-        return readable
-
     def filter_threads(self, request, forum, queryset):
         try:
             forum_role = self.acl[forum.pk]
@@ -201,6 +193,13 @@ class ThreadsACL(BaseACL):
             return False
         return queryset
 
+    def can_read_threads(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_read_threads']
+        except KeyError:
+            return False
+
     def can_start_threads(self, forum):
         try:
             forum_role = self.acl[forum.pk]
@@ -384,6 +383,13 @@ class ThreadsACL(BaseACL):
         except KeyError:
             return False
 
+    def can_close(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_close_threads']
+        except KeyError:
+            return False
+
     def can_protect(self, forum):
         try:
             forum_role = self.acl[forum.pk]
@@ -391,6 +397,13 @@ class ThreadsACL(BaseACL):
         except KeyError:
             return False
 
+    def can_pin_threads(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_pin_threads']
+        except KeyError:
+            return False
+
     def can_delete_thread(self, user, forum, thread, post):
         try:
             forum_role = self.acl[forum.pk]
@@ -560,7 +573,6 @@ def build_forums(acl, perms, forums, forum_roles):
                      'can_approve': False,
                      'can_edit_labels': False,
                      'can_see_changelog': False,
-                     'can_make_annoucements': False,
                      'can_pin_threads': 0,
                      'can_edit_threads_posts': False,
                      'can_move_threads_posts': False,

+ 14 - 11
misago/usercp/acl.py → misago/acl/permissions/usercp.py

@@ -6,7 +6,7 @@ from misago.acl.builder import BaseACL
 from misago.forms import YesNoSwitch
 
 def make_form(request, role, form):
-    if role.token != 'guest':
+    if role.special != 'guest':
         form.base_fields['name_changes_allowed'] = forms.IntegerField(min_value=0, initial=1)
         form.base_fields['changes_expire'] = forms.IntegerField(min_value=0, initial=0)
         form.base_fields['can_use_signature'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
@@ -65,17 +65,20 @@ def build(acl, roles):
     acl.usercp.acl['signature_images'] = False
 
     for role in roles:
-        if 'name_changes_allowed' in role and role['name_changes_allowed'] > acl.usercp.acl['name_changes_allowed']:
-            acl.usercp.acl['name_changes_allowed'] = role['name_changes_allowed']
+        try:
+            if 'name_changes_allowed' in role and role['name_changes_allowed'] > acl.usercp.acl['name_changes_allowed']:
+                acl.usercp.acl['name_changes_allowed'] = role['name_changes_allowed']
 
-        if 'changes_expire' in role and role['changes_expire'] > acl.usercp.acl['changes_expire']:
-            acl.usercp.acl['changes_expire'] = role['changes_expire']
+            if 'changes_expire' in role and role['changes_expire'] > acl.usercp.acl['changes_expire']:
+                acl.usercp.acl['changes_expire'] = role['changes_expire']
 
-        if 'can_use_signature' in role and role['can_use_signature']:
-            acl.usercp.acl['signature'] = role['can_use_signature']
+            if 'can_use_signature' in role and role['can_use_signature']:
+                acl.usercp.acl['signature'] = role['can_use_signature']
 
-        if 'allow_signature_links' in role and role['allow_signature_links']:
-            acl.usercp.acl['signature_links'] = role['allow_signature_links']
+            if 'allow_signature_links' in role and role['allow_signature_links']:
+                acl.usercp.acl['signature_links'] = role['allow_signature_links']
 
-        if 'allow_signature_images' in role and role['allow_signature_images']:
-            acl.usercp.acl['signature_images'] = role['allow_signature_images']
+            if 'allow_signature_images' in role and role['allow_signature_images']:
+                acl.usercp.acl['signature_images'] = role['allow_signature_images']
+        except KeyError:
+            pass

+ 12 - 9
misago/users/acl.py → misago/acl/permissions/users.py

@@ -1,7 +1,7 @@
 from django import forms
 from django.utils.translation import ugettext_lazy as _
 from misago.acl.builder import BaseACL
-from misago.acl.utils import ACLError404
+from misago.acl.exceptions import ACLError404
 from misago.forms import YesNoSwitch
 
 def make_form(request, role, form):
@@ -47,14 +47,17 @@ def build(acl, roles):
     acl.users.acl['can_see_hidden_users'] = False
 
     for role in roles:
-        if 'can_search_users' in role and role['can_search_users']:
-            acl.users.acl['can_search_users'] = True
+        try:
+            if 'can_search_users' in role and role['can_search_users']:
+                acl.users.acl['can_search_users'] = True
 
-        if 'can_see_users_emails' in role and role['can_see_users_emails']:
-            acl.users.acl['can_see_users_emails'] = True
+            if 'can_see_users_emails' in role and role['can_see_users_emails']:
+                acl.users.acl['can_see_users_emails'] = True
 
-        if 'can_see_users_trails' in role and role['can_see_users_trails']:
-            acl.users.acl['can_see_users_trails'] = True
+            if 'can_see_users_trails' in role and role['can_see_users_trails']:
+                acl.users.acl['can_see_users_trails'] = True
 
-        if 'can_see_hidden_users' in role and role['can_see_hidden_users']:
-            acl.users.acl['can_see_hidden_users'] = True
+            if 'can_see_hidden_users' in role and role['can_see_hidden_users']:
+                acl.users.acl['can_see_hidden_users'] = True
+        except KeyError:
+            pass

+ 0 - 17
misago/acl/utils.py

@@ -1,17 +0,0 @@
-from misago.views import error403, error404
-
-class ACLError403(Exception):
-    pass
-
-class ACLError404(Exception):
-    pass
-
-def acl_errors(f):
-    def decorator(*args, **kwargs):
-        try:
-            return f(*args, **kwargs)
-        except ACLError403 as e:
-            return error403(args[0], e.message)
-        except ACLError404 as e:
-            return error404(args[0], e.message)
-    return decorator

+ 3 - 2
misago/admin/__init__.py → misago/admin.py

@@ -147,13 +147,13 @@ class AdminSite(object):
         late_actions = []
 
         # Load default admin site
-        from misago.admin.layout.sections import ADMIN_SECTIONS
+        from misago.apps.admin.sections import ADMIN_SECTIONS
         for section in ADMIN_SECTIONS:
             self.sections.append(section)
             self.sections_index[section.id] = section
 
             # Loop section actions
-            section_actions = import_module('misago.admin.layout.%s' % section.id)
+            section_actions = import_module('misago.apps.admin.sections.%s' % section.id)
             for action in section_actions.ADMIN_ACTIONS:
                 self.actions_index[action.id] = action
                 if not action.after:
@@ -211,6 +211,7 @@ class AdminSite(object):
                 first_section = False
             else:
                 self.routes += patterns('', url(('^%s/' % section.id), include(section.get_routes())))
+        
         return self.routes
 
     def get_action(self, action):

+ 0 - 29
misago/admin/acl.py

@@ -1,29 +0,0 @@
-from django.utils.translation import ugettext_lazy as _
-from django import forms
-from misago.acl.builder import BaseACL
-from misago.forms import YesNoSwitch
-
-def make_form(request, role, form):
-    if not role.token and request.user.is_god():
-        form.base_fields['can_use_acp'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
-        form.layout.append((
-                            _("Admin Control Panel"),
-                            (('can_use_acp', {'label': _("Can use Admin Control Panel"), 'help_text': _("Change this permission to yes to grant admin access for users with this role.")}),),
-                            ))
-
-
-class AdminACL(BaseACL):
-    def is_admin(self):
-        return self.acl['can_use_acp']
-
-
-def build(acl, roles):
-    acl.admin = AdminACL()
-    acl.admin.acl['can_use_acp'] = False
-
-    for role in roles:
-        if 'can_use_acp' in role and role['can_use_acp'] > acl.admin.acl['can_use_acp']:
-            acl.admin.acl['can_use_acp'] = role['can_use_acp']
-
-    if acl.admin.acl['can_use_acp']:
-        acl.team = True

+ 0 - 4
misago/admin/context_processors.py

@@ -1,4 +0,0 @@
-from misago.admin import site
-
-def admin(request):
-    return site.get_admin_navigation(request)

+ 0 - 119
misago/alerts/migrations/0001_initial.py

@@ -1,119 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Alert'
-        db.create_table(u'alerts_alert', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['users.User'])),
-            ('date', self.gf('django.db.models.fields.DateTimeField')()),
-            ('message', self.gf('django.db.models.fields.TextField')()),
-            ('variables', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-        ))
-        db.send_create_signal(u'alerts', ['Alert'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Alert'
-        db.delete_table(u'alerts_alert')
-
-
-    models = {
-        u'alerts.alert': {
-            'Meta': {'object_name': 'Alert'},
-            'date': ('django.db.models.fields.DateTimeField', [], {}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'message': ('django.db.models.fields.TextField', [], {}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']"}),
-            'variables': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
-        },
-        u'ranks.rank': {
-            'Meta': {'object_name': 'Rank'},
-            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'roles.role': {
-            'Meta': {'object_name': 'Role'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'users.user': {
-            'Meta': {'object_name': 'User'},
-            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'allow_pms': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
-            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
-            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
-            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
-            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ranks.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['roles.Role']", 'symmetrical': 'False'}),
-            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
-            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        }
-    }
-
-    complete_apps = ['alerts']

+ 0 - 0
misago/admin/layout/__init__.py → misago/apps/__init__.py


+ 0 - 0
misago/alerts/__init__.py → misago/apps/activation/__init__.py


+ 4 - 6
misago/activation/forms.py → misago/apps/activation/forms.py

@@ -2,15 +2,13 @@ import hashlib
 from django import forms
 from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext_lazy as _
-from misago.forms import Form
-from misago import captcha
-from misago.users.models import User
-
+from misago.forms import Form, QACaptchaField, ReCaptchaField
+from misago.models import User
 
 class UserSendActivationMailForm(Form):
     email = forms.EmailField(max_length=255)
-    captcha_qa = captcha.QACaptchaField()
-    recaptcha = captcha.ReCaptchaField()
+    captcha_qa = QACaptchaField()
+    recaptcha = ReCaptchaField()
     error_source = 'email'
 
     layout = [

+ 1 - 1
misago/activation/urls.py → misago/apps/activation/urls.py

@@ -1,6 +1,6 @@
 from django.conf.urls import patterns, url
 
-urlpatterns = patterns('misago.activation.views',
+urlpatterns = patterns('misago.apps.activation.views',
     url(r'^request/$', 'form', name="send_activation"),
     url(r'^(?P<username>[a-z0-9]+)-(?P<user>\d+)/(?P<token>[a-zA-Z0-9]+)/$', 'activate', name="activate"),
 )

+ 9 - 14
misago/activation/views.py → misago/apps/activation/views.py

@@ -1,18 +1,13 @@
 from django.template import RequestContext
 from django.utils.translation import ugettext as _
-from misago.banning.models import check_ban
-from misago.banning.decorators import block_banned
-from misago.banning.views import error_banned
-from misago.crawlers.decorators import block_crawlers
-from misago.forms.layouts import FormLayout
-from misago.authn.methods import sign_user_in
-from misago.authn.decorators import block_authenticated
-from misago.activation.forms import UserSendActivationMailForm
-from misago.bruteforce.decorators import block_jammed
+from misago.apps.errors import error404, error_banned
+from misago.auth import sign_user_in
+from misago.decorators import block_authenticated, block_banned, block_crawlers, block_jammed
+from misago.forms import FormLayout
 from misago.messages import Message
-from misago.users.models import User
-from misago.views import redirect_message, error404
-
+from misago.models import Ban, User
+from misago.utils.views import redirect_message
+from misago.apps.activation.forms import UserSendActivationMailForm
 
 @block_crawlers
 @block_banned
@@ -24,7 +19,7 @@ def form(request):
         form = UserSendActivationMailForm(request.POST, request=request)
         if form.is_valid():
             user = form.found_user
-            user_ban = check_ban(username=user.username, email=user.email)
+            user_ban = Ban.objects.check_ban(username=user.username, email=user.email)
 
             if user_ban:
                 return error_banned(request, user, user_ban)
@@ -64,7 +59,7 @@ def activate(request, username="", user="0", token=""):
         current_activation = user.activation
 
         # Run checks
-        user_ban = check_ban(username=user.username, email=user.email)
+        user_ban = Ban.objects.check_ban(username=user.username, email=user.email)
         if user_ban:
             return error_banned(request, user, user_ban)
 

+ 0 - 0
misago/alerts/management/__init__.py → misago/apps/admin/__init__.py


+ 0 - 0
misago/alerts/management/commands/__init__.py → misago/apps/admin/bans/__init__.py


+ 14 - 14
misago/banning/forms.py → misago/apps/admin/bans/forms.py

@@ -6,12 +6,12 @@ class BanForm(Form):
     """
     New/Edit Ban form
     """
-    type = forms.ChoiceField(choices=(
-                                      (0, _('Ban Username and e-mail')),
-                                      (1, _('Ban Username')),
-                                      (2, _('Ban E-mail address')),
-                                      (3, _('Ban IP Address'))
-                                      ))
+    test = forms.TypedChoiceField(choices=(
+                                           (0, _('Ban Username and e-mail')),
+                                           (1, _('Ban Username')),
+                                           (2, _('Ban E-mail address')),
+                                           (3, _('Ban IP Address'))
+                                           ), coerce=int)
     reason_user = forms.CharField(widget=forms.Textarea, required=False)
     reason_admin = forms.CharField(widget=forms.Textarea, required=False)
     ban = forms.CharField(max_length=255)
@@ -20,7 +20,7 @@ class BanForm(Form):
                (
                  _("Ban Details"),
                  (
-                  ('nested', (('type', {'label': _("Ban Rule"), 'help_text': _("Select ban type from list and define rule by entering it in text field. If you want to ban specific user, enter here either his Username or E-mail address. If you want to define blanket ban, you can use wildcard (\"*\"). For example to forbid all members from using name suggesting that member is an admin, you can set ban that forbids \"Admin*\" as username."), 'width': 25}),
+                  ('nested', (('test', {'label': _("Ban Rule"), 'help_text': _("Select ban type from list and define rule by entering it in text field. If you want to ban specific user, enter here either his Username or E-mail address. If you want to define blanket ban, you can use wildcard (\"*\"). For example to forbid all members from using name suggesting that member is an admin, you can set ban that forbids \"Admin*\" as username."), 'width': 25}),
                   ('ban', {'width': 75}))),
                   ('expires', {'label': _("Ban Expiration"), 'help_text': _("If you want to, you can set this ban's expiration date by entering it here using YYYY-MM-DD format. Otherwhise you can leave this field empty making this ban permanent.")}),
                  ),
@@ -38,19 +38,19 @@ class BanForm(Form):
 class SearchBansForm(Form):
     ban = forms.CharField(required=False)
     reason = forms.CharField(required=False)
-    type = forms.MultipleChoiceField(widget=forms.CheckboxSelectMultiple, choices=(
-                                      ("0", _('Username and e-mail')),
-                                      ("1", _('Username')),
-                                      ("2", _('E-mail address')),
-                                      ("3", _('IP Address'))
-                                      ), required=False)
+    test = forms.TypedMultipleChoiceField(widget=forms.CheckboxSelectMultiple, choices=(
+                                          (0, _('Username and e-mail')),
+                                          (1, _('Username')),
+                                          (2, _('E-mail address')),
+                                          (3, _('IP Address'))
+                                          ), coerce=int, required=False)
     layout = (
               (
                _("Search Bans"),
                (
                 ('ban', {'label': _("Ban"), 'attrs': {'placeholder': _("Ban contains...")}}),
                 ('reason', {'label': _("Messages"), 'attrs': {'placeholder': _("User or Team message contains...")}}),
-                ('type', {'label': _("Type")}),
+                ('test', {'label': _("Type")}),
                ),
               ),
              )

+ 13 - 30
misago/banning/views.py → misago/apps/admin/bans/views.py

@@ -3,37 +3,20 @@ from django.db.models import Q
 from django.template import RequestContext
 from django.utils.translation import ugettext as _
 from misago.admin import site
-from misago.admin.widgets import *
-from misago.banning.forms import BanForm, SearchBansForm
-from misago.banning.models import Ban
+from misago.apps.admin.widgets import *
 from misago.messages import Message
+from misago.models import Ban
+from misago.apps.admin.bans.forms import BanForm, SearchBansForm
 
-"""
-Admin mixin
-"""
 def reverse(route, target=None):
     if target:
         return django_reverse(route, kwargs={'target': target.pk})
     return django_reverse(route)
 
+
 """
 Views
 """
-def error_banned(request, user=None, ban=None):
-    if not ban:
-        ban = request.ban
-    response = request.theme.render_to_response('error403_banned.html',
-                                                {
-                                                 'banned_user': user,
-                                                 'ban': ban,
-                                                 'hide_signin': True,
-                                                 'exception_response': True,
-                                                 },
-                                                context_instance=RequestContext(request));
-    response.status_code = 403
-    return response
-
-
 class List(ListWidget):
     """
     List Bans
@@ -63,8 +46,8 @@ class List(ListWidget):
             model = model.filter(ban__contains=filters['ban'])
         if 'reason' in filters:
             model = model.filter(Q(reason_user__contains=filters['reason']) | Q(reason_admin__contains=filters['reason']))
-        if 'type' in filters:
-            model = model.filter(type__in=filters['type'])
+        if 'test' in filters:
+            model = model.filter(test__in=filters['test'])
         return model
 
     def get_item_actions(self, item):
@@ -97,7 +80,7 @@ class New(FormWidget):
 
     def submit_form(self, form, target):
         new_ban = Ban(
-                      type=form.cleaned_data['type'],
+                      test=form.cleaned_data['test'],
                       ban=form.cleaned_data['ban'],
                       reason_user=form.cleaned_data['reason_user'],
                       reason_admin=form.cleaned_data['reason_admin'],
@@ -129,7 +112,7 @@ class Edit(FormWidget):
 
     def get_initial_data(self, model):
         return {
-                'type': model.type,
+                'test': model.test,
                 'ban': model.ban,
                 'reason_user': model.reason_user,
                 'reason_admin': model.reason_admin,
@@ -137,7 +120,7 @@ class Edit(FormWidget):
                 }
 
     def submit_form(self, form, target):
-        target.type = form.cleaned_data['type']
+        target.test = form.cleaned_data['test']
         target.ban = form.cleaned_data['ban']
         target.reason_user = form.cleaned_data['reason_user']
         target.reason_admin = form.cleaned_data['reason_admin']
@@ -159,11 +142,11 @@ class Delete(ButtonWidget):
     def action(self, target):
         target.delete()
         self.request.monitor['bans_version'] = int(self.request.monitor['bans_version']) + 1
-        if target.type == 0:
+        if target.test == 0:
             return Message(_('E-mail and username Ban "%(ban)s" has been lifted.') % {'ban': target.ban}, 'success'), False
-        if target.type == 1:
+        if target.test == 1:
             return Message(_('Username Ban "%(ban)s" has been lifted.') % {'ban': target.ban}, 'success'), False
-        if target.type == 2:
+        if target.test == 2:
             return Message(_('E-mail Ban "%(ban)s" has been lifted.') % {'ban': target.ban}, 'success'), False
-        if target.type == 3:
+        if target.test == 3:
             return Message(_('IP Ban "%(ban)s" has been lifted.') % {'ban': target.ban}, 'success'), False

+ 0 - 0
misago/alerts/migrations/__init__.py → misago/apps/admin/clients/__init__.py


+ 2 - 2
misago/themes/forms.py → misago/apps/admin/clients/forms.py

@@ -3,8 +3,8 @@ from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext_lazy as _
 from django import forms
 from misago.forms import Form
-from misago.themes.models import ThemeAdjustment
-from misago.utils.validators import validate_sluggable
+from misago.models import ThemeAdjustment
+from misago.validators import validate_sluggable
 
 available_themes = []
 for theme in settings.INSTALLED_THEMES[0:-1]:

+ 12 - 11
misago/themes/views.py → misago/apps/admin/clients/views.py

@@ -2,17 +2,18 @@ from django.core.urlresolvers import reverse as django_reverse
 from django import forms
 from django.utils.translation import ugettext as _
 from misago.admin import site
-from misago.admin.widgets import *
+from misago.apps.admin.widgets import *
 from misago.forms import Form
-from misago.utils import slugify
-from misago.themes.forms import ThemeAdjustmentForm
-from misago.themes.models import ThemeAdjustment
+from misago.models import ThemeAdjustment
+from misago.utils.strings import slugify
+from misago.apps.admin.clients.forms import ThemeAdjustmentForm
 
 def reverse(route, target=None):
     if target:
         return django_reverse(route, kwargs={'target': target.pk, 'slug': slugify(target.theme)})
     return django_reverse(route)
 
+
 """
 Views
 """
@@ -43,7 +44,7 @@ class New(FormWidget):
     id = 'new'
     fallback = 'admin_clients'
     form = ThemeAdjustmentForm
-    submit_button = _("Save Rank")
+    submit_button = _("Set Adjustment")
 
     def get_form_instance(self, form, model, initial, post=False):
         if post:
@@ -57,17 +58,17 @@ class New(FormWidget):
         return reverse('admin_clients_edit', model)
 
     def submit_form(self, form, target):
-        new_rank = ThemeAdjustment.objects.create(
-                                                  theme=form.cleaned_data['theme'],
-                                                  useragents='\r\n'.join(form.cleaned_data['useragents']),
-                                                  )
-        return new_rank, Message(_('New adjustment has been created.'), 'success')
+        new_adjustment = ThemeAdjustment.objects.create(
+                                                        theme=form.cleaned_data['theme'],
+                                                        useragents='\r\n'.join(form.cleaned_data['useragents']),
+                                                        )
+        return new_adjustment, Message(_('New adjustment has been created.'), 'success')
 
 
 class Edit(FormWidget):
     admin = site.get_action('clients')
     id = 'edit'
-    name = _("Edit Rank")
+    name = _("Edit Adjustment")
     fallback = 'admin_clients'
     form = ThemeAdjustmentForm
     target_name = 'theme'

+ 0 - 0
misago/authn/__init__.py → misago/apps/admin/forumroles/__init__.py


+ 1 - 1
misago/forumroles/forms.py → misago/apps/admin/forumroles/forms.py

@@ -1,7 +1,7 @@
 from django.utils.translation import ugettext_lazy as _
 from django import forms
 from misago.forms import Form
-from misago.utils.validators import validate_sluggable
+from misago.validators import validate_sluggable
 
 class ForumRoleForm(Form):
     name = forms.CharField(max_length=255, validators=[validate_sluggable(

+ 8 - 7
misago/forumroles/views.py → misago/apps/admin/forumroles/views.py

@@ -3,17 +3,18 @@ from django.core.urlresolvers import reverse as django_reverse
 from django.utils.translation import ugettext as _
 from misago.acl.builder import build_forum_form
 from misago.admin import site
-from misago.admin.widgets import *
-from misago.utils import slugify
+from misago.apps.admin.widgets import *
 from misago.forms import Form, YesNoSwitch
-from misago.forumroles.forms import ForumRoleForm
-from misago.forumroles.models import ForumRole
+from misago.models import ForumRole
+from misago.utils.strings import slugify
+from misago.apps.admin.forumroles.forms import ForumRoleForm
 
 def reverse(route, target=None):
     if target:
         return django_reverse(route, kwargs={'target': target.pk, 'slug': slugify(target.name)})
     return django_reverse(route)
 
+
 """
 Views
 """
@@ -113,7 +114,7 @@ class ACL(FormWidget):
         return self.get_url(model)
 
     def get_initial_data(self, model):
-        raw_acl = model.get_permissions()
+        raw_acl = model.permissions
         initial = {}
         for field in self.form.base_fields:
             if field in raw_acl:
@@ -121,10 +122,10 @@ class ACL(FormWidget):
         return initial
 
     def submit_form(self, form, target):
-        raw_acl = target.get_permissions()
+        raw_acl = target.permissions
         for perm in form.cleaned_data:
             raw_acl[perm] = form.cleaned_data[perm]
-        target.set_permissions(raw_acl)
+        target.permissions = raw_acl
         target.save(force_update=True)
         self.request.monitor['acl_version'] = int(self.request.monitor['acl_version']) + 1
 

+ 0 - 0
misago/banning/__init__.py → misago/apps/admin/forums/__init__.py


+ 10 - 10
misago/forums/forms.py → misago/apps/admin/forums/forms.py

@@ -2,8 +2,8 @@ from django.utils.translation import ugettext_lazy as _
 from django import forms
 from mptt.forms import TreeNodeChoiceField
 from misago.forms import Form, YesNoSwitch
-from misago.forums.models import Forum
-from misago.utils.validators import validate_sluggable
+from misago.models import Forum
+from misago.validators import validate_sluggable
 
 class CategoryForm(Form):
     parent = False
@@ -40,8 +40,8 @@ class CategoryForm(Form):
              )
 
     def finalize_form(self):
-        self.fields['parent'] = TreeNodeChoiceField(queryset=Forum.tree.get(token='root').get_descendants(include_self=True), level_indicator=u'- - ')
-        self.fields['perms'] = TreeNodeChoiceField(queryset=Forum.tree.get(token='root').get_descendants(), level_indicator=u'- - ', required=False, empty_label=_("Don't copy permissions"))
+        self.fields['parent'] = TreeNodeChoiceField(queryset=Forum.objects.get(special='root').get_descendants(include_self=True), level_indicator=u'- - ')
+        self.fields['perms'] = TreeNodeChoiceField(queryset=Forum.objects.get(special='root').get_descendants(), level_indicator=u'- - ', required=False, empty_label=_("Don't copy permissions"))
 
     def clean_attrs(self):
         clean = []
@@ -97,8 +97,8 @@ class ForumForm(Form):
               )
 
     def finalize_form(self):
-        self.fields['parent'] = TreeNodeChoiceField(queryset=Forum.tree.get(token='root').get_descendants(), level_indicator=u'- - ')
-        self.fields['perms'] = TreeNodeChoiceField(queryset=Forum.tree.get(token='root').get_descendants(), level_indicator=u'- - ', required=False, empty_label=_("Don't copy permissions"))
+        self.fields['parent'] = TreeNodeChoiceField(queryset=Forum.objects.get(special='root').get_descendants(), level_indicator=u'- - ')
+        self.fields['perms'] = TreeNodeChoiceField(queryset=Forum.objects.get(special='root').get_descendants(), level_indicator=u'- - ', required=False, empty_label=_("Don't copy permissions"))
 
     def clean_attrs(self):
         clean = []
@@ -141,8 +141,8 @@ class RedirectForm(Form):
               )
 
     def finalize_form(self):
-        self.fields['parent'] = TreeNodeChoiceField(queryset=Forum.tree.get(token='root').get_descendants(), level_indicator=u'- - ')
-        self.fields['perms'] = TreeNodeChoiceField(queryset=Forum.tree.get(token='root').get_descendants(), level_indicator=u'- - ', required=False, empty_label=_("Don't copy permissions"))
+        self.fields['parent'] = TreeNodeChoiceField(queryset=Forum.objects.get(special='root').get_descendants(), level_indicator=u'- - ')
+        self.fields['perms'] = TreeNodeChoiceField(queryset=Forum.objects.get(special='root').get_descendants(), level_indicator=u'- - ', required=False, empty_label=_("Don't copy permissions"))
 
 
 class DeleteForm(Form):
@@ -161,8 +161,8 @@ class DeleteForm(Form):
         super(DeleteForm, self).__init__(*args, **kwargs)
 
     def finalize_form(self):
-        self.fields['contents'] = TreeNodeChoiceField(queryset=Forum.tree.get(token='root').get_descendants(), required=False, empty_label=_("Remove with forum"), level_indicator=u'- - ')
-        self.fields['subforums'] = TreeNodeChoiceField(queryset=Forum.tree.get(token='root').get_descendants(), required=False, empty_label=_("Remove with forum"), level_indicator=u'- - ')
+        self.fields['contents'] = TreeNodeChoiceField(queryset=Forum.objects.get(special='root').get_descendants(), required=False, empty_label=_("Remove with forum"), level_indicator=u'- - ')
+        self.fields['subforums'] = TreeNodeChoiceField(queryset=Forum.objects.get(special='root').get_descendants(), required=False, empty_label=_("Remove with forum"), level_indicator=u'- - ')
 
     def clean_contents(self):
         data = self.cleaned_data['contents']

+ 10 - 9
misago/forums/views.py → misago/apps/admin/forums/views.py

@@ -4,16 +4,17 @@ from django.db.models import Q
 from django.utils.translation import ugettext as _
 from mptt.forms import TreeNodeChoiceField
 from misago.admin import site
-from misago.admin.widgets import *
-from misago.forums.forms import CategoryForm, ForumForm, RedirectForm, DeleteForm
-from misago.forums.models import Forum
-from misago.utils import slugify
+from misago.apps.admin.widgets import *
+from misago.models import Forum
+from misago.utils.strings import slugify
+from misago.apps.admin.forums.forms import CategoryForm, ForumForm, RedirectForm, DeleteForm
 
 def reverse(route, target=None):
     if target:
         return django_reverse(route, kwargs={'target': target.pk, 'slug': target.slug})
     return django_reverse(route)
 
+
 """
 Views
 """
@@ -31,7 +32,7 @@ class List(ListWidget):
     empty_message = _('No forums are currently defined.')
 
     def get_items(self):
-        return self.admin.model.objects.get(token='root').get_descendants()
+        return self.admin.model.objects.get(special='root').get_descendants()
 
     def sort_items(self, page_items, sorting_method):
         return page_items.order_by('lft')
@@ -137,7 +138,7 @@ class NewForum(FormWidget):
         return new_forum, Message(_('New Forum has been created.'), 'success')
 
     def __call__(self, request):
-        if self.admin.model.objects.get(token='root').get_descendants().count() == 0:
+        if self.admin.model.objects.get(special='root').get_descendants().count() == 0:
             request.messages.set_flash(Message(_("You have to create at least one category before you will be able to create forums.")), 'error', self.admin.id)
             return redirect(self.get_fallback_url())
         return super(NewForum, self).__call__(request)
@@ -175,7 +176,7 @@ class NewRedirect(FormWidget):
         return new_forum, Message(_('New Redirect has been created.'), 'success')
 
     def __call__(self, request):
-        if self.admin.model.objects.get(token='root').get_descendants().count() == 0:
+        if self.admin.model.objects.get(special='root').get_descendants().count() == 0:
             request.messages.set_flash(Message(_("You have to create at least one category before you will be able to create redirects.")), 'error', self.admin.id)
             return redirect(self.get_fallback_url())
         return super(NewRedirect, self).__call__(request)
@@ -236,7 +237,7 @@ class Edit(FormWidget):
 
     def get_form_instance(self, form, target, initial, post=False):
         form_inst = super(Edit, self).get_form_instance(form, target, initial, post)
-        valid_targets = Forum.tree.get(token='root').get_descendants(include_self=target.type == 'category').exclude(Q(lft__gte=target.lft) & Q(rght__lte=target.rght))
+        valid_targets = Forum.objects.get(special='root').get_descendants(include_self=target.type == 'category').exclude(Q(lft__gte=target.lft) & Q(rght__lte=target.rght))
         form_inst.fields['parent'] = TreeNodeChoiceField(queryset=valid_targets, level_indicator=u'- - ')
         return form_inst
 
@@ -321,7 +322,7 @@ class Delete(FormWidget):
             form_inst = form(forum=target, request=self.request, initial=self.get_initial_data(target))
         if target.type != 'forum':
             del form_inst.fields['contents']
-        valid_targets = Forum.tree.get(token='root').get_descendants().exclude(Q(lft__gte=target.lft) & Q(rght__lte=target.rght))
+        valid_targets = Forum.objects.get(special='root').get_descendants().exclude(Q(lft__gte=target.lft) & Q(rght__lte=target.rght))
         form_inst.fields['subforums'] = TreeNodeChoiceField(queryset=valid_targets, required=False, empty_label=_("Remove with forum"), level_indicator=u'- - ')
         return form_inst
 

+ 2 - 1
misago/admin/views.py → misago/apps/admin/home.py

@@ -1,7 +1,8 @@
 from django.template import RequestContext
-from misago.sessions.models import Session
+from misago.models import Session
 
 def home(request):
+    print 'BAZONGA!'
     return request.theme.render_to_response('home.html', {
         'users': request.monitor['users'],
         'users_inactive': request.monitor['users_inactive'],

+ 15 - 0
misago/apps/admin/index.py

@@ -0,0 +1,15 @@
+from django.template import RequestContext
+from misago.models import Session
+
+def index(request):
+    return request.theme.render_to_response('index.html', {
+        'users': request.monitor['users'],
+        'users_inactive': request.monitor['users_inactive'],
+        'threads': request.monitor['threads'],
+        'posts': request.monitor['posts'],
+        'admins': Session.objects.filter(user__isnull=False).filter(admin=1).order_by('user__username_slug').select_related('user'),
+        }, context_instance=RequestContext(request));
+
+
+def todo(request, *args, **kwargs):
+    return request.theme.render_to_response('todo.html', context_instance=RequestContext(request));

+ 0 - 0
misago/banning/migrations/__init__.py → misago/apps/admin/newsletters/__init__.py


+ 2 - 2
misago/newsletters/forms.py → misago/apps/admin/newsletters/forms.py

@@ -2,8 +2,8 @@ from django.core.validators import RegexValidator
 from django.utils.translation import ugettext_lazy as _
 from django import forms
 from misago.forms import Form, YesNoSwitch
-from misago.ranks.models import Rank
-from misago.utils.validators import validate_sluggable
+from misago.models import Rank
+from misago.validators import validate_sluggable
 
 class NewsletterForm(Form):
     name = forms.CharField(max_length=255, validators=[validate_sluggable(

+ 4 - 5
misago/newsletters/views.py → misago/apps/admin/newsletters/views.py

@@ -5,10 +5,9 @@ from django.shortcuts import redirect
 from django.template import RequestContext
 from django.utils.translation import ugettext as _
 from misago.admin import site
-from misago.admin.widgets import *
-from misago.newsletters.forms import NewsletterForm, SearchNewslettersForm
-from misago.newsletters.models import Newsletter
-from misago.users.models import User
+from misago.apps.admin.widgets import *
+from misago.models import Newsletter, User
+from misago.apps.admin.newsletters.forms import NewsletterForm, SearchNewslettersForm
 
 def reverse(route, target=None):
     if target:
@@ -17,6 +16,7 @@ def reverse(route, target=None):
         return django_reverse(route, kwargs={'target': target.pk})
     return django_reverse(route)
 
+
 """
 Views
 """
@@ -86,7 +86,6 @@ class New(FormWidget):
 
         for rank in form.cleaned_data['ranks']:
             new_newsletter.ranks.add(rank)
-        new_newsletter.save(force_update=True)
 
         return new_newsletter, Message(_('New Newsletter has been created.'), 'success')
 

+ 0 - 0
misago/bruteforce/__init__.py → misago/apps/admin/online/__init__.py


+ 0 - 0
misago/sessions/forms.py → misago/apps/admin/online/forms.py


+ 2 - 2
misago/sessions/views.py → misago/apps/admin/online/views.py

@@ -1,7 +1,7 @@
 from django.utils.translation import ugettext as _
 from misago.admin import site
-from misago.admin.widgets import ListWidget
-from misago.sessions.forms import SearchSessionsForm
+from misago.apps.admin.widgets import ListWidget
+from misago.apps.admin.online.forms import SearchSessionsForm
 
 class List(ListWidget):
     admin = site.get_action('online')

+ 0 - 0
misago/bruteforce/management/__init__.py → misago/apps/admin/pruneusers/__init__.py


+ 1 - 1
misago/prune/forms.py → misago/apps/admin/pruneusers/forms.py

@@ -1,7 +1,7 @@
 from django.utils.translation import ugettext_lazy as _
 from django import forms
 from misago.forms import Form
-from misago.utils.validators import validate_sluggable
+from misago.validators import validate_sluggable
 
 class PolicyForm(Form):
     name = forms.CharField(max_length=255, validators=[validate_sluggable(

+ 4 - 4
misago/prune/views.py → misago/apps/admin/pruneusers/views.py

@@ -2,17 +2,17 @@ from django.core.urlresolvers import reverse as django_reverse
 from django import forms
 from django.utils.translation import ungettext, ugettext as _
 from misago.admin import site
-from misago.admin.widgets import *
+from misago.apps.admin.widgets import *
 from misago.forms import Form
-from misago.prune.forms import PolicyForm
-from misago.prune.models import Policy
-from misago.users.models import User
+from misago.models import PruningPolicy, User
+from misago.apps.admin.pruneusers.forms import PolicyForm
 
 def reverse(route, target=None):
     if target:
         return django_reverse(route, kwargs={'target': target.pk})
     return django_reverse(route)
 
+
 """
 Views
 """

+ 0 - 0
misago/bruteforce/management/commands/__init__.py → misago/apps/admin/ranks/__init__.py


+ 1 - 1
misago/ranks/forms.py → misago/apps/admin/ranks/forms.py

@@ -2,7 +2,7 @@ from django.core.validators import RegexValidator
 from django.utils.translation import ugettext_lazy as _
 from django import forms
 from misago.forms import Form, YesNoSwitch
-from misago.utils.validators import validate_sluggable
+from misago.validators import validate_sluggable
 
 class RankForm(Form):
     name = forms.CharField(max_length=255, validators=[validate_sluggable(

+ 7 - 6
misago/ranks/views.py → misago/apps/admin/ranks/views.py

@@ -2,17 +2,18 @@ from django.core.urlresolvers import reverse as django_reverse
 from django import forms
 from django.utils.translation import ugettext as _
 from misago.admin import site
-from misago.admin.widgets import *
+from misago.apps.admin.widgets import *
 from misago.forms import Form
-from misago.utils import slugify
-from misago.ranks.forms import RankForm
-from misago.ranks.models import Rank
+from misago.models import Rank
+from misago.utils.strings import slugify
+from misago.apps.admin.ranks.forms import RankForm
 
 def reverse(route, target=None):
     if target:
         return django_reverse(route, kwargs={'target': target.pk, 'slug': slugify(target.name)})
     return django_reverse(route)
 
+
 """
 Views
 """
@@ -83,7 +84,7 @@ class New(FormWidget):
         last_rank = Rank.objects.latest('order')
         new_rank = Rank(
                       name=form.cleaned_data['name'],
-                      name_slug=slugify(form.cleaned_data['name']),
+                      slug=slugify(form.cleaned_data['name']),
                       description=form.cleaned_data['description'],
                       style=form.cleaned_data['style'],
                       title=form.cleaned_data['title'],
@@ -127,7 +128,7 @@ class Edit(FormWidget):
 
     def submit_form(self, form, target):
         target.name = form.cleaned_data['name']
-        target.name_slug = slugify(form.cleaned_data['name'])
+        target.slug = slugify(form.cleaned_data['name'])
         target.description = form.cleaned_data['description']
         target.style = form.cleaned_data['style']
         target.title = form.cleaned_data['title']

+ 0 - 0
misago/bruteforce/migrations/__init__.py → misago/apps/admin/roles/__init__.py


+ 1 - 1
misago/roles/forms.py → misago/apps/admin/roles/forms.py

@@ -1,7 +1,7 @@
 from django.utils.translation import ugettext_lazy as _
 from django import forms
 from misago.forms import Form, YesNoSwitch
-from misago.utils.validators import validate_sluggable
+from misago.validators import validate_sluggable
 
 class RoleForm(Form):
     name = forms.CharField(max_length=255,validators=[validate_sluggable(

+ 15 - 27
misago/roles/views.py → misago/apps/admin/roles/views.py

@@ -4,19 +4,18 @@ from django.shortcuts import redirect
 from django.utils.translation import ugettext as _
 from misago.acl.builder import build_form 
 from misago.admin import site
-from misago.admin.widgets import *
-from misago.utils import slugify
+from misago.apps.admin.widgets import *
 from misago.forms import Form, YesNoSwitch
-from misago.forums.models import Forum
-from misago.forumroles.models import ForumRole
-from misago.roles.forms import RoleForm
-from misago.roles.models import Role
+from misago.models import Forum, ForumRole, Role
+from misago.utils.strings import slugify
+from misago.apps.admin.roles.forms import RoleForm
 
 def reverse(route, target=None):
     if target:
         return django_reverse(route, kwargs={'target': target.pk, 'slug': slugify(target.name)})
     return django_reverse(route)
 
+
 """
 Views
 """
@@ -45,7 +44,7 @@ class List(ListWidget):
     def action_delete(self, items, checked):
         for item in items:
             if unicode(item.pk) in checked:
-                if item.token:
+                if item.special:
                     return Message(_('You cannot delete system roles.'), 'error'), reverse('admin_roles')
                 if item.protected and not self.request.user.is_god():
                     return Message(_('You cannot delete protected roles.'), 'error'), reverse('admin_roles')
@@ -128,21 +127,10 @@ class Forums(ListWidget):
         return reverse('admin_roles_masks', self.role) 
     
     def get_items(self):
-        return Forum.objects.get(token='root').get_descendants()
+        return Forum.objects.get(special='root').get_descendants()
     
     def sort_items(self, page_items, sorting_method):
-        final_items = []
-        for forum in Forum.objects.filter(token__in=['annoucements', 'reports', 'private']).order_by('token'):
-            if forum.token == 'annoucements':
-                forum.name = _("Global Annoucements")
-            if forum.token == 'reports':
-                forum.name = _("Reports")
-            if forum.token == 'private':
-                forum.name = _("Private Discussions")
-            final_items.append(forum)
-        for forum in page_items.order_by('lft').all():
-            final_items.append(forum)
-        return final_items
+        return page_items.order_by('lft').all()
 
     def add_template_variables(self, variables):
         variables['target'] = _(self.role.name)
@@ -151,7 +139,7 @@ class Forums(ListWidget):
     def get_table_form(self, page_items):
         perms = {}
         try:
-            forums = self.role.get_permissions()['forums']
+            forums = self.role.permissions['forums']
             for fid in forums:
                perms[str(fid)] = str(forums[fid])
         except KeyError:
@@ -173,9 +161,9 @@ class Forums(ListWidget):
         for item in page_items:
             if cleaned_data['forum_' + str(item.pk)] != "0":
                 perms[item.pk] = long(cleaned_data['forum_' + str(item.pk)])
-        role_perms = self.role.get_permissions()
+        role_perms = self.role.permissions
         role_perms['forums'] = perms
-        self.role.set_permissions(role_perms)
+        self.role.permissions = role_perms
         self.role.save(force_update=True)
         return Message(_('Forum permissions have been saved.'), 'success'), self.get_url()
         
@@ -218,7 +206,7 @@ class ACL(FormWidget):
         return self.get_url(model)
     
     def get_initial_data(self, model):
-        raw_acl = model.get_permissions()
+        raw_acl = model.permissions
         initial = {}
         for field in self.form.base_fields:
             if field in raw_acl:
@@ -233,10 +221,10 @@ class ACL(FormWidget):
         return result
     
     def submit_form(self, form, target):
-        raw_acl = target.get_permissions()
+        raw_acl = target.permissions
         for perm in form.cleaned_data:
             raw_acl[perm] = form.cleaned_data[perm]
-        target.set_permissions(raw_acl)
+        target.permissions = raw_acl
         target.save(force_update=True)
         self.request.monitor['acl_version'] = int(self.request.monitor['acl_version']) + 1
         
@@ -250,7 +238,7 @@ class Delete(ButtonWidget):
     notfound_message = _('Requested Role could not be found.')
     
     def action(self, target):
-        if target.token:
+        if target.special:
             return Message(_('You cannot delete system roles.'), 'error'), reverse('admin_roles')
         if target.protected and not self.request.user.is_god():
             return Message(_('This role is protected.'), 'error'), reverse('admin_roles')

+ 0 - 0
misago/admin/layout/sections.py → misago/apps/admin/sections/__init__.py


+ 6 - 6
misago/admin/layout/forums.py → misago/apps/admin/sections/forums.py

@@ -1,7 +1,7 @@
 from django.conf.urls import patterns, include, url
 from django.utils.translation import ugettext_lazy as _
 from misago.admin import AdminAction
-from misago.forums.models import Forum
+from misago.models import Forum
 
 ADMIN_ACTIONS = (
    AdminAction(
@@ -38,7 +38,7 @@ ADMIN_ACTIONS = (
                          },
                         ],
                route='admin_forums',
-               urlpatterns=patterns('misago.forums.views',
+               urlpatterns=patterns('misago.apps.admin.forums.views',
                         url(r'^$', 'List', name='admin_forums'),
                         url(r'^new/category/$', 'NewCategory', name='admin_forums_new_category'),
                         url(r'^new/forum/$', 'NewForum', name='admin_forums_new_forum'),
@@ -56,7 +56,7 @@ ADMIN_ACTIONS = (
                help=_("Thread Labels allow you to group threads together within forums."),
                icon='tags',
                route='admin_forums_labels',
-               urlpatterns=patterns('misago.admin.views',
+               urlpatterns=patterns('misago.apps.admin.index',
                         url(r'^$', 'todo', name='admin_forums_labels'),
                     ),
                ),
@@ -67,7 +67,7 @@ ADMIN_ACTIONS = (
                help=_("Forbid usage of words in messages"),
                icon='volume-off',
                route='admin_forums_badwords',
-               urlpatterns=patterns('misago.admin.views',
+               urlpatterns=patterns('misago.apps.admin.index',
                         url(r'^$', 'todo', name='admin_forums_badwords'),
                     ),
                ),
@@ -78,7 +78,7 @@ ADMIN_ACTIONS = (
                help=_("Tests that new messages have to pass"),
                icon='filter',
                route='admin_forums_tests',
-               urlpatterns=patterns('misago.admin.views',
+               urlpatterns=patterns('misago.apps.admin.index',
                         url(r'^$', 'todo', name='admin_forums_tests'),
                     ),
                ),
@@ -89,7 +89,7 @@ ADMIN_ACTIONS = (
                help=_("Manage allowed attachment types."),
                icon='download-alt',
                route='admin_forums_attachments',
-               urlpatterns=patterns('misago.admin.views',
+               urlpatterns=patterns('misago.apps.admin.index',
                         url(r'^$', 'todo', name='admin_forums_attachments'),
                     ),
                ),

+ 10 - 11
misago/admin/layout/overview.py → misago/apps/admin/sections/overview.py

@@ -1,34 +1,33 @@
 from django.conf.urls import patterns, include, url
 from django.utils.translation import ugettext_lazy as _
 from misago.admin import AdminAction
-from misago.sessions.models import Session
-from misago.users.models import User
+from misago.models import Session, User
 
 ADMIN_ACTIONS = (
    AdminAction(
                section='overview',
-               id='home',
+               id='index',
                name=_("Home"),
                help=_("Your forums right now"),
                icon='home',
                route='admin_home',
-               urlpatterns=patterns('misago.admin.views',
-                        url(r'^$', 'home', name='admin_home'),
+               urlpatterns=patterns('misago.apps.admin.index',
+                        url(r'^$', 'index', name='admin_home'),
                     ),
                ),
-   AdminAction(
+    AdminAction(
                section='overview',
                id='stats',
                name=_("Stats"),
                help=_("Create Statistics Reports"),
                icon='signal',
                route='admin_stats',
-               urlpatterns=patterns('misago.stats.views',
+               urlpatterns=patterns('misago.apps.admin.stats.views',
                         url(r'^$', 'form', name='admin_stats'),
                         url(r'^(?P<model>[a-z0-9]+)/(?P<date_start>[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9])/(?P<date_end>[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9])/(?P<precision>\w+)$', 'graph', name='admin_stats_graph'),
                     ),
                ),
-   AdminAction(
+    AdminAction(
                section='overview',
                id='online',
                name=_("Online"),
@@ -44,12 +43,12 @@ ADMIN_ACTIONS = (
                          },
                         ],
                route='admin_online',
-               urlpatterns=patterns('misago.sessions.views',
+               urlpatterns=patterns('misago.apps.admin.online.views',
                         url(r'^$', 'List', name='admin_online'),
                         url(r'^(?P<page>\d+)/$', 'List', name='admin_online'),
                     ),
                ),
-   AdminAction(
+    AdminAction(
                section='overview',
                id='team',
                name=_("Forum Team"),
@@ -65,7 +64,7 @@ ADMIN_ACTIONS = (
                          },
                         ],
                route='admin_team',
-               urlpatterns=patterns('misago.team.views',
+               urlpatterns=patterns('misago.apps.admin.team',
                         url(r'^$', 'List', name='admin_team'),
                     ),
                ),

+ 3 - 5
misago/admin/layout/perms.py → misago/apps/admin/sections/perms.py

@@ -1,9 +1,7 @@
 from django.conf.urls import patterns, include, url
 from django.utils.translation import ugettext_lazy as _
 from misago.admin import AdminAction
-from misago.roles.models import Role
-from misago.forumroles.models import ForumRole
-
+from misago.models import ForumRole, Role
 
 ADMIN_ACTIONS = (
    AdminAction(
@@ -28,7 +26,7 @@ ADMIN_ACTIONS = (
                          },
                         ],
                route='admin_roles',
-               urlpatterns=patterns('misago.roles.views',
+               urlpatterns=patterns('misago.apps.admin.roles.views',
                         url(r'^$', 'List', name='admin_roles'),
                         url(r'^new/$', 'New', name='admin_roles_new'),
                         url(r'^forums/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Forums', name='admin_roles_masks'),
@@ -59,7 +57,7 @@ ADMIN_ACTIONS = (
                          },
                         ],
                route='admin_roles_forums',
-               urlpatterns=patterns('misago.forumroles.views',
+               urlpatterns=patterns('misago.apps.admin.forumroles.views',
                         url(r'^$', 'List', name='admin_roles_forums'),
                         url(r'^new/$', 'New', name='admin_roles_forums_new'),
                         url(r'^acl/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'ACL', name='admin_roles_forums_acl'),

+ 3 - 3
misago/admin/layout/system.py → misago/apps/admin/sections/system.py

@@ -1,7 +1,7 @@
 from django.conf.urls import patterns, include, url
 from django.utils.translation import ugettext_lazy as _
 from misago.admin import AdminAction
-from misago.themes.models import ThemeAdjustment
+from misago.models import ThemeAdjustment
 
 ADMIN_ACTIONS = (
    AdminAction(
@@ -11,7 +11,7 @@ ADMIN_ACTIONS = (
                help=_("Change your forum configuration"),
                icon='wrench',
                route='admin_settings',
-               urlpatterns=patterns('misago.settings.views',
+               urlpatterns=patterns('misago.apps.admin.settings.views',
                         url(r'^$', 'settings', name='admin_settings'),
                         url(r'^search/$', 'settings_search', name='admin_settings_search'),
                         url(r'^(?P<group_slug>([a-z0-9]|-)+)-(?P<group_id>\d+)/$', 'settings', name='admin_settings')
@@ -39,7 +39,7 @@ ADMIN_ACTIONS = (
                          },
                         ],
                route='admin_clients',
-               urlpatterns=patterns('misago.themes.views',
+               urlpatterns=patterns('misago.apps.admin.clients.views',
                         url(r'^$', 'List', name='admin_clients'),
                         url(r'^new/$', 'New', name='admin_clients_new'),
                         url(r'^edit/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Edit', name='admin_clients_edit'),

+ 8 - 12
misago/admin/layout/users.py → misago/apps/admin/sections/users.py

@@ -1,11 +1,7 @@
 from django.conf.urls import patterns, include, url
 from django.utils.translation import ugettext_lazy as _
 from misago.admin import AdminAction
-from misago.banning.models import Ban
-from misago.newsletters.models import Newsletter
-from misago.prune.models import Policy
-from misago.ranks.models import Rank
-from misago.users.models import User
+from misago.models import Ban, Newsletter, PruningPolicy, Rank, User
 
 ADMIN_ACTIONS = (
    AdminAction(
@@ -30,7 +26,7 @@ ADMIN_ACTIONS = (
                          },
                         ],
                route='admin_users',
-               urlpatterns=patterns('misago.users.views',
+               urlpatterns=patterns('misago.apps.admin.users.views',
                         url(r'^$', 'List', name='admin_users'),
                         url(r'^(?P<page>\d+)/$', 'List', name='admin_users'),
                         url(r'^inactive/$', 'inactive', name='admin_users_inactive'),
@@ -61,7 +57,7 @@ ADMIN_ACTIONS = (
                          },
                         ],
                route='admin_ranks',
-               urlpatterns=patterns('misago.ranks.views',
+               urlpatterns=patterns('misago.apps.admin.ranks.views',
                         url(r'^$', 'List', name='admin_ranks'),
                         url(r'^new/$', 'New', name='admin_ranks_new'),
                         url(r'^edit/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Edit', name='admin_ranks_edit'),
@@ -71,7 +67,7 @@ ADMIN_ACTIONS = (
    AdminAction(
                section='users',
                id='bans',
-               name=_("Banning"),
+               name=_("Bans"),
                help=_("Ban or unban users from forums."),
                icon='lock',
                model=Ban,
@@ -90,7 +86,7 @@ ADMIN_ACTIONS = (
                          },
                         ],
                route='admin_bans',
-               urlpatterns=patterns('misago.banning.views',
+               urlpatterns=patterns('misago.apps.admin.bans.views',
                         url(r'^$', 'List', name='admin_bans'),
                         url(r'^(?P<page>\d+)/$', 'List', name='admin_bans'),
                         url(r'^new/$', 'New', name='admin_bans_new'),
@@ -104,7 +100,7 @@ ADMIN_ACTIONS = (
                name=_("Prune Users"),
                help=_("Delete multiple Users"),
                icon='remove',
-               model=Policy,
+               model=PruningPolicy,
                actions=[
                         {
                          'id': 'list',
@@ -120,7 +116,7 @@ ADMIN_ACTIONS = (
                          },
                         ],
                route='admin_prune_users',
-               urlpatterns=patterns('misago.prune.views',
+               urlpatterns=patterns('misago.apps.admin.pruneusers.views',
                         url(r'^$', 'List', name='admin_prune_users'),
                         url(r'^new/$', 'New', name='admin_prune_users_new'),
                         url(r'^edit/(?P<target>\d+)/$', 'Edit', name='admin_prune_users_edit'),
@@ -150,7 +146,7 @@ ADMIN_ACTIONS = (
                          },
                         ],
                route='admin_newsletters',
-               urlpatterns=patterns('misago.newsletters.views',
+               urlpatterns=patterns('misago.apps.admin.newsletters.views',
                         url(r'^$', 'List', name='admin_newsletters'),
                         url(r'^(?P<page>\d+)/$', 'List', name='admin_newsletters'),
                         url(r'^new/$', 'New', name='admin_newsletters_new'),

+ 0 - 0
misago/cookie_jar/__init__.py → misago/apps/admin/settings/__init__.py


+ 0 - 0
misago/settings/forms.py → misago/apps/admin/settings/forms.py


+ 7 - 8
misago/settings/views.py → misago/apps/admin/settings/views.py

@@ -2,17 +2,16 @@ from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
 from django.template import RequestContext
 from django.utils.translation import ungettext, ugettext as _
-from misago.forms import Form
-from misago.forms.layouts import FormLayout, FormFields
+from misago.forms import Form, FormLayout, FormFields
 from misago.messages import Message
 from misago.search import SearchQuery, SearchException
-from misago.settings.forms import SearchForm
-from misago.settings.models import Group, Setting
-from misago.views import error404
+from misago.models import SettingsGroup, Setting
+from misago.apps.errors import error404
+from misago.apps.admin.settings.forms import SearchForm
 
 def settings(request, group_id=None, group_slug=None):
     # Load groups and find selected group
-    settings_groups = Group.objects.all().order_by('key')
+    settings_groups = SettingsGroup.objects.all().order_by('key')
     if not group_id:
         active_group = settings_groups[0]
         group_id = active_group.pk
@@ -71,7 +70,7 @@ def settings(request, group_id=None, group_slug=None):
 
 
 def settings_search(request):
-    settings_groups = Group.objects.all().order_by('key')
+    settings_groups = SettingsGroup.objects.all().order_by('key')
     message = None
     found_settings = []
     try:
@@ -103,7 +102,7 @@ def settings_search(request):
         else:
             raise SearchException(_('Search query is invalid.'))
     except SearchException as e:
-        message = Message(e.message, 'error')
+        message = Message(unicode(e), 'error')
     return request.theme.render_to_response('settings/search_results.html',
                                     {
                                     'message': message,

+ 0 - 0
misago/crawlers/__init__.py → misago/apps/admin/stats/__init__.py


+ 0 - 0
misago/stats/forms.py → misago/apps/admin/stats/forms.py


+ 2 - 2
misago/stats/views.py → misago/apps/admin/stats/views.py

@@ -8,8 +8,8 @@ from django.utils import timezone
 from django.utils.translation import ugettext as _
 from misago.forms import FormLayout
 from misago.messages import Message
-from misago.stats.forms import GenerateStatisticsForm
-from misago.views import error404
+from misago.apps.admin.stats.forms import GenerateStatisticsForm
+from misago.apps.errors import error404
 
 def form(request):
     """

+ 1 - 1
misago/team/views.py → misago/apps/admin/team.py

@@ -1,6 +1,6 @@
 from django.utils.translation import ugettext as _
 from misago.admin import site
-from misago.admin.widgets import ListWidget
+from misago.apps.admin.widgets import ListWidget
 
 class List(ListWidget):
     admin = site.get_action('team')

+ 0 - 0
misago/firewalls/__init__.py → misago/apps/admin/users/__init__.py


+ 2 - 4
misago/users/forms.py → misago/apps/admin/users/forms.py

@@ -3,11 +3,9 @@ from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext_lazy as _
 from django import forms
-from misago.ranks.models import Rank
-from misago.roles.models import Role
-from misago.users.models import User
-from misago.users.validators import validate_username, validate_password, validate_email
 from misago.forms import Form, YesNoSwitch
+from misago.models import Rank, Role, User
+from misago.validators import validate_username, validate_password, validate_email
 
 class UserForm(Form):
     username = forms.CharField(max_length=255)

+ 10 - 10
misago/users/views.py → misago/apps/admin/users/views.py

@@ -3,18 +3,18 @@ from django.db.models import Q
 from django.shortcuts import redirect
 from django.utils.translation import ugettext as _
 from misago.admin import site
-from misago.admin.widgets import *
-from misago.forums.models import Forum
+from misago.apps.admin.widgets import *
 from misago.markdown import signature_markdown
-from misago.users.forms import UserForm, NewUserForm, SearchUsersForm
-from misago.users.models import User
-from misago.utils import get_random_string
+from misago.models import Forum, User
+from misago.utils.strings import random_string
+from misago.apps.admin.users.forms import UserForm, NewUserForm, SearchUsersForm
 
 def reverse(route, target=None):
     if target:
         return django_reverse(route, kwargs={'target': target.pk, 'slug': target.username_slug})
     return django_reverse(route)
 
+
 """
 Views
 """
@@ -115,7 +115,7 @@ class List(ListWidget):
         for user in items:
             if unicode(user.pk) in checked:
                 user.activation = user.ACTIVATION_USER
-                user.token = token = get_random_string(12)
+                user.token = token = random_string(12)
                 user.save(force_update=True)
                 user.email_user(
                                 self.request,
@@ -177,7 +177,7 @@ class List(ListWidget):
         # Second loop - reset passwords
         for user in items:
             if unicode(user.pk) in checked:
-                new_password = get_random_string(8)
+                new_password = random_string(8)
                 user.set_password(new_password)
                 user.save(force_update=True)
                 user.email_user(
@@ -315,7 +315,7 @@ class Edit(FormWidget):
         # Do signature mumbo-jumbo
         if form.cleaned_data['signature']:
             target.signature = form.cleaned_data['signature']
-            target.signature_preparsed = signature_markdown(target.get_acl(self.request),
+            target.signature_preparsed = signature_markdown(target.acl(self.request),
                                                             form.cleaned_data['signature'])
         else:
             target.signature = None
@@ -369,6 +369,6 @@ class Delete(ButtonWidget):
 
 
 def inactive(request):
-    token = 'list_filter_misago.users.models.User'
-    request.session[token] = {'activation': ['1', '2', '3']}
+    token = 'list_filter.users.User'
+    request.session[token] = {'activation': [1, 2, 3]}
     return redirect(reverse('admin_users'))

+ 1 - 3
misago/admin/widgets.py → misago/apps/admin/widgets.py

@@ -6,10 +6,8 @@ from django.template import RequestContext
 from django.utils.translation import ugettext_lazy as _
 from jinja2 import TemplateNotFound
 import math
-from misago.forms import Form
-from misago.forms.layouts import *
+from misago.forms import Form, FormLayout, FormFields, FormFieldsets
 from misago.messages import Message
-from misago.search import SearchException
 
 """
 Class widgets

+ 3 - 3
misago/alerts/views.py → misago/apps/alerts.py

@@ -1,15 +1,15 @@
 from django.template import RequestContext
 from django.utils import timezone
 from django.utils.translation import ugettext as _
-from misago.authn.decorators import block_guest
-from misago.views import error404
+from misago.decorators import block_guest
 
 @block_guest
-def show_alerts(request):
+def alerts(request):
     now = timezone.now()
     alerts = {}
     if not request.user.alerts_date:
         request.user.alerts_date = request.user.join_date
+
     for alert in request.user.alert_set.order_by('-id'):
         alert.new = alert.date > request.user.alerts_date
         diff = now - alert.date

+ 22 - 0
misago/apps/category.py

@@ -0,0 +1,22 @@
+from django.template import RequestContext
+from misago.apps.errors import error403, error404
+from misago.models import Forum
+from misago.readstrackers import ForumsTracker
+
+def category(request, forum, slug):
+    if not request.acl.forums.can_see(forum):
+        return error404(request)
+    try:
+        forum = Forum.objects.get(pk=forum, type='category')
+        if not request.acl.forums.can_browse(forum):
+            return error403(request, _("You don't have permission to browse this category."))
+    except Forum.DoesNotExist:
+        return error404(request)
+
+    forum.subforums = Forum.objects.treelist(request.acl.forums, forum, tracker=ForumsTracker(request.user))
+    return request.theme.render_to_response('category.html',
+                                            {
+                                             'category': forum,
+                                             'parents': Forum.objects.forum_parents(forum.pk),
+                                             },
+                                            context_instance=RequestContext(request));

+ 53 - 0
misago/apps/errors.py

@@ -0,0 +1,53 @@
+from django.template import RequestContext
+from django.utils.translation import ugettext as _
+from misago.utils.views import json_response
+
+def error_not_implemented(request, *args, **kwargs):
+    """Generic "NOT IMPLEMENTED!" Error"""
+    raise NotImplementedError("This action is not implemented!")
+
+
+def error_view(request, error, message=None):
+    if message:
+        message = unicode(message)
+    if request.is_ajax():
+        if not message:
+            if error == 404:
+                message = _("Requested page could not be loaded.")
+            if error == 403:
+                message = _("You don't have permission to see requested page.")
+        return json_response(request, status=error, message=message)
+    response = request.theme.render_to_response(('error%s.html' % error),
+                                                {
+                                                 'message': message,
+                                                 'hide_signin': True,
+                                                 'exception_response': True,
+                                                 },
+                                                context_instance=RequestContext(request));
+    response.status_code = error
+    return response
+
+
+def error403(request, message=None):
+    return error_view(request, 403, message)
+
+
+def error404(request, message=None):
+    return error_view(request, 404, message)
+
+
+def error_banned(request, user=None, ban=None):
+    if not ban:
+        ban = request.ban
+    if request.is_ajax():
+        return json_response(request, status=403, message=_("You are banned."))
+    response = request.theme.render_to_response('error403_banned.html',
+                                                {
+                                                 'banned_user': user,
+                                                 'ban': ban,
+                                                 'hide_signin': True,
+                                                 'exception_response': True,
+                                                 },
+                                                context_instance=RequestContext(request));
+    response.status_code = 403
+    return response

+ 9 - 0
misago/apps/forummap.py

@@ -0,0 +1,9 @@
+from django.template import RequestContext
+from misago.models import Forum
+
+def forum_map(request):
+    return request.theme.render_to_response('forum_map.html',
+                                            {
+                                             'forums': Forum.objects.treelist(request.acl.forums),
+                                             },
+                                            context_instance=RequestContext(request));

+ 69 - 0
misago/apps/index.py

@@ -0,0 +1,69 @@
+from datetime import timedelta
+from django.core.cache import cache
+from django.template import RequestContext
+from django.utils import timezone
+from misago.models import Forum, Post, Rank, Session, Thread
+from misago.readstrackers import ForumsTracker
+
+def index(request):
+    # Threads ranking
+    popular_threads = []
+    if request.settings['thread_ranking_size'] > 0:
+        popular_threads = cache.get('thread_ranking_%s' % request.user.make_acl_key(), 'nada')
+        if popular_threads == 'nada':
+            popular_threads = []
+            for thread in Thread.objects.filter(moderated=False).filter(deleted=False).filter(forum__in=Forum.objects.readable_forums(request.acl)).prefetch_related('forum').order_by('-score')[:request.settings['thread_ranking_size']]:
+                thread.forum_name = thread.forum.name
+                thread.forum_slug = thread.forum.slug
+                popular_threads.append(thread)
+            cache.set('thread_ranking_%s' % request.user.make_acl_key(), popular_threads, 60 * request.settings['thread_ranking_refresh'])
+
+    # Ranks online
+    ranks_list = cache.get('ranks_online', 'nada')
+    if ranks_list == 'nada':
+        ranks_dict = {}
+        ranks_list = []
+        users_list = []
+        for rank in Rank.objects.filter(on_index=True).order_by('order'):
+            rank_entry = {'id':rank.id, 'name': rank.name, 'style': rank.style, 'title': rank.title, 'online': []}
+            ranks_list.append(rank_entry)
+            ranks_dict[rank.pk] = rank_entry
+        if ranks_dict:
+            for session in Session.objects.select_related('user').filter(rank__in=ranks_dict.keys()).filter(last__gte=timezone.now() - timedelta(minutes=10)).filter(user__isnull=False):
+                if not session.user_id in users_list:
+                    ranks_dict[session.user.rank_id]['online'].append(session.user)
+                    users_list.append(session.user_id)
+            # Assert we are on list
+            if (request.user.is_authenticated() and request.user.rank_id in ranks_dict.keys()
+                and not request.user.id in users_list):
+                    ranks_dict[request.user.rank_id]['online'].append(request.user)
+            del ranks_dict
+            del users_list
+        cache.set('ranks_online', ranks_list, 300)
+
+    # Users online
+    users_online = cache.get('users_online', 'nada')
+    if users_online == 'nada':
+        users_online = Session.objects.filter(matched=True).filter(crawler__isnull=True).filter(last__gte=timezone.now() - timedelta(seconds=300)).count()
+        cache.set('users_online', users_online, 300)
+    if not users_online and not request.user.is_crawler():
+        # Cheatey trick to make sure we'll never display
+        # zero users online to human client
+        users_online = 1
+
+    # Load reads tracker and build forums list
+    reads_tracker = ForumsTracker(request.user)
+    forums_list = Forum.objects.treelist(request.acl.forums, tracker=reads_tracker)
+    
+    # Whitelist ignored members
+    Forum.objects.ignored_users(request.user, forums_list)
+    
+    # Render page
+    return request.theme.render_to_response('index.html',
+                                            {
+                                             'forums_list': forums_list,
+                                             'ranks_online': ranks_list,
+                                             'users_online': users_online,
+                                             'popular_threads': popular_threads,
+                                             },
+                                            context_instance=RequestContext(request));

+ 3 - 3
misago/newsfeed/views.py → misago/apps/newsfeed.py

@@ -1,6 +1,6 @@
 from django.template import RequestContext
-from misago.authn.decorators import block_guest
-from misago.threads.models import Post
+from misago.decorators import block_guest
+from misago.models import Forum, Post
 
 @block_guest
 def newsfeed(request):
@@ -9,7 +9,7 @@ def newsfeed(request):
         follows.append(user.pk)
     queryset = []
     if follows:
-        queryset = Post.objects.filter(forum_id__in=request.acl.threads.get_readable_forums(request.acl))
+        queryset = Post.objects.filter(forum_id__in=Forum.objects.readable_forums(request.acl))
         queryset = queryset.filter(deleted=False).filter(moderated=False)
         queryset = queryset.filter(user_id__in=follows)
         queryset = queryset.prefetch_related('thread', 'forum', 'user').order_by('-id')

+ 22 - 0
misago/apps/newthreads.py

@@ -0,0 +1,22 @@
+from datetime import timedelta
+from django.template import RequestContext
+from django.utils import timezone
+from misago.models import Forum, Thread
+from misago.utils.pagination import make_pagination
+
+def new_threads(request, page=0):
+    queryset = Thread.objects.filter(forum_id__in=Forum.objects.readable_forums(request.acl)).filter(deleted=False).filter(moderated=False).filter(start__gte=(timezone.now() - timedelta(days=2)))
+    items_total = queryset.count();
+    pagination = make_pagination(page, items_total, 30)
+
+    queryset = queryset.order_by('-start').prefetch_related('forum')[pagination['start']:pagination['stop']];
+    if request.settings['avatars_on_threads_list']:
+        queryset = queryset.prefetch_related('start_poster', 'last_poster')
+
+    return request.theme.render_to_response('new_threads.html',
+                                            {
+                                             'items_total': items_total,
+                                             'threads': Thread.objects.with_reads(queryset, request.user),
+                                             'pagination': pagination,
+                                             },
+                                            context_instance=RequestContext(request));

+ 22 - 0
misago/apps/popularthreads.py

@@ -0,0 +1,22 @@
+from datetime import timedelta
+from django.template import RequestContext
+from django.utils import timezone
+from misago.models import Forum, Thread
+from misago.utils.pagination import make_pagination
+
+def popular_threads(request, page=0):
+    queryset = Thread.objects.filter(forum_id__in=Forum.objects.readable_forums(request.acl)).filter(deleted=False).filter(moderated=False)
+    items_total = queryset.count();
+    pagination = make_pagination(page, items_total, 30)
+
+    queryset = queryset.order_by('-score').prefetch_related('forum')[pagination['start']:pagination['stop']];
+    if request.settings['avatars_on_threads_list']:
+        queryset = queryset.prefetch_related('start_poster', 'last_poster')
+
+    return request.theme.render_to_response('popular_threads.html',
+                                            {
+                                             'items_total': items_total,
+                                             'threads': Thread.objects.with_reads(queryset, request.user),
+                                             'pagination': pagination,
+                                             },
+                                            context_instance=RequestContext(request));

+ 0 - 0
misago/forumroles/__init__.py → misago/apps/privatethreads/__init__.py


+ 15 - 0
misago/apps/privatethreads/changelog.py

@@ -0,0 +1,15 @@
+from misago.apps.threadtype.changelog import (ChangelogChangesBaseView,
+                                              ChangelogDiffBaseView,
+                                              ChangelogRevertBaseView)
+from misago.apps.privatethreads.mixins import TypeMixin
+
+class ChangelogView(ChangelogChangesBaseView, TypeMixin):
+    pass
+
+
+class ChangelogDiffView(ChangelogDiffBaseView, TypeMixin):
+    pass
+
+
+class ChangelogRevertView(ChangelogRevertBaseView, TypeMixin):
+    pass

+ 17 - 0
misago/apps/privatethreads/delete.py

@@ -0,0 +1,17 @@
+from misago.apps.threadtype.delete import *
+from misago.apps.privatethreads.mixins import TypeMixin
+
+class DeleteThreadView(DeleteThreadBaseView, TypeMixin):
+    pass
+
+
+class HideThreadView(HideThreadBaseView, TypeMixin):
+    pass
+
+
+class DeleteReplyView(DeleteReplyBaseView, TypeMixin):
+    pass
+
+
+class HideReplyView(HideReplyBaseView, TypeMixin):
+    pass

+ 9 - 0
misago/apps/privatethreads/details.py

@@ -0,0 +1,9 @@
+from misago.apps.threadtype.details import DetailsBaseView, KarmaVotesBaseView
+from misago.apps.privatethreads.mixins import TypeMixin
+
+class DetailsView(DetailsBaseView, TypeMixin):
+    pass
+
+
+class KarmaVotesView(KarmaVotesBaseView, TypeMixin):
+    pass

+ 62 - 0
misago/apps/privatethreads/forms.py

@@ -0,0 +1,62 @@
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+from misago.apps.threadtype.posting.forms import (NewThreadForm as NewThreadBaseForm,
+                                                  EditThreadForm as EditThreadBaseForm,
+                                                  NewReplyForm as NewReplyBaseForm,
+                                                  EditReplyForm as EditReplyBaseForm)
+from misago.forms import Form
+from misago.models import User
+from misago.utils.strings import slugify
+
+class InviteUsersMixin(object):
+    def type_fields(self):
+        self.layout[0][1].append(('invite_users', {'label': _("Invite members to thread"), 'attrs': {'placeholder': _("user1, user2, user3...")}}))
+        self.fields['invite_users'] = forms.CharField(max_length=255, required=False)
+
+    def clean_invite_users(self):
+        self.invite_users = []
+        usernames = []
+        slugs = [self.request.user.username_slug]
+        for username in self.cleaned_data['invite_users'].split(','):
+            username = username.strip()
+            slug = slugify(username)
+            if len(slug) > 3 and not slug in slugs:
+                slugs.append(slug)
+                usernames.append(username)
+                try:
+                    user = User.objects.get(username_slug=slug)
+                    if not user.acl(self.request).private_threads.can_participate():
+                        raise forms.ValidationError(_('%(user)s cannot participate in private threads.') % {'user': user.username})
+                    if (not self.request.acl.private_threads.can_invite_ignoring() and
+                        user.allow_pd_invite(self.request.user)):
+                        raise forms.ValidationError(_('%(user)s restricts who can invite him to private threads.') % {'user': user.username})
+                    self.invite_users.append(user)
+                except User.DoesNotExist:
+                    raise forms.ValidationError(_('User "%(username)s" could not be found.') % {'username': username})
+            if len(usernames) > 8:
+                raise forms.ValidationError(_('You cannot invite more than 8 members at single time. Post thread and then invite additional members.'))
+        return ', '.join(usernames)
+
+
+class NewThreadForm(NewThreadBaseForm, InviteUsersMixin):
+    include_thread_weight = False
+    include_close_thread = False
+
+
+class EditThreadForm(EditThreadBaseForm):
+    include_thread_weight = False
+    include_close_thread = False
+
+
+class NewReplyForm(NewReplyBaseForm, InviteUsersMixin):
+    include_thread_weight = False
+    include_close_thread = False
+
+
+class EditReplyForm(EditReplyBaseForm):
+    include_thread_weight = False
+    include_close_thread = False
+
+
+class InviteMemberForm(Form):
+    username = forms.CharField(max_length=200)

+ 115 - 0
misago/apps/privatethreads/jumps.py

@@ -0,0 +1,115 @@
+from django.utils.translation import ugettext as _
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.apps.threadtype.jumps import *
+from misago.models import User
+from misago.utils.strings import slugify
+from misago.apps.privatethreads.mixins import TypeMixin
+
+class LastReplyView(LastReplyBaseView, TypeMixin):
+    pass
+
+
+class FindReplyView(FindReplyBaseView, TypeMixin):
+    pass
+
+
+class NewReplyView(NewReplyBaseView, TypeMixin):
+    pass
+
+
+class ShowHiddenRepliesView(ShowHiddenRepliesBaseView, TypeMixin):
+    pass
+
+
+class WatchThreadView(WatchThreadBaseView, TypeMixin):
+    pass
+
+
+class WatchEmailThreadView(WatchEmailThreadBaseView, TypeMixin):
+    pass
+
+
+class UnwatchThreadView(UnwatchThreadBaseView, TypeMixin):
+    pass
+
+
+class UnwatchEmailThreadView(UnwatchEmailThreadBaseView, TypeMixin):
+    pass
+
+
+class UpvotePostView(UpvotePostBaseView, TypeMixin):
+    pass
+
+
+class DownvotePostView(DownvotePostBaseView, TypeMixin):
+    pass
+
+
+class InviteUserView(JumpView, TypeMixin):
+    def make_jump(self):
+        username = slugify(self.request.POST.get('username', '').strip())
+        if not username:
+            self.request.messages.set_flash(Message(_('You have to enter name of user you want to invite to thread.')), 'error', 'threads')
+            return self.retreat_redirect()
+        try:
+            user = User.objects.get(username_slug=username)
+            acl = user.acl(self.request)
+            if user in self.thread.participants.all():
+                if user.pk == self.request.user.pk:
+                    self.request.messages.set_flash(Message(_('You cannot add yourself to this thread.')), 'error', 'threads')
+                else:
+                    self.request.messages.set_flash(Message(_('%(user)s is already participating in this thread.') % {'user': user.username}), 'info', 'threads')
+            if not acl.private_threads.can_participate():
+                    self.request.messages.set_flash(Message(_('%(user)s cannot participate in private threads.') % {'user': user.username}), 'info', 'threads')
+            elif (not self.request.acl.private_threads.can_invite_ignoring() and
+                    user.allow_pd_invite(self.request.user)):
+                self.request.messages.set_flash(Message(_('%(user)s restricts who can invite him to private threads.') % {'user': user.username}), 'info', 'threads')
+            else:
+                self.thread.participants.add(user)
+                user.sync_pds = True
+                user.save(force_update=True)
+                user.email_user(self.request, 'private_thread_invite', _("You've been invited to private thread \"%(thread)s\" by %(user)s") % {'thread': self.thread.name, 'user': self.request.user.username}, {'author': self.request.user, 'thread': self.thread})
+                self.thread.last_post.set_checkpoint(self.request, 'invited', user)
+                self.thread.last_post.save(force_update=True)
+                self.request.messages.set_flash(Message(_('%(user)s has been added to this thread.') % {'user': user.username}), 'success', 'threads')
+            return self.retreat_redirect()
+        except User.DoesNotExist:
+            self.request.messages.set_flash(Message(_('User with requested username could not be found.')), 'error', 'threads')
+            return self.retreat_redirect()
+
+
+class RemoveUserView(JumpView, TypeMixin):
+    def make_jump(self):
+        target_user = int(self.request.POST.get('user', 0))
+        if (not (self.request.user.pk == self.thread.start_poster_id or
+                self.request.acl.private_threads.is_mod()) and
+                target_user != self.request.user.pk):
+            raise ACLError403(_("You don't have permission to remove discussion participants."))
+        try:
+            user = self.thread.participants.get(id=target_user)
+            self.thread.participants.remove(user)
+            self.thread.threadread_set.filter(id=user.pk).delete()
+            self.thread.watchedthread_set.filter(id=user.pk).delete()
+            user.sync_pds = True
+            user.save(force_update=True)
+            # If there are no more participants in thread, remove it
+            if self.thread.participants.count() == 0:
+                self.thread.delete()
+                self.request.messages.set_flash(Message(_('Thread has been deleted because last participant left it.')), 'info', 'threads')
+                return self.threads_list_redirect()
+            # Nope, see if we removed ourselves
+            if user.pk == self.request.user.pk:
+                self.thread.last_post.set_checkpoint(self.request, 'left')
+                self.thread.last_post.save(force_update=True)
+                self.request.messages.set_flash(Message(_('You have left the "%(thread)s" thread.') % {'thread': self.thread.name}), 'info', 'threads')
+                return self.threads_list_redirect()
+            # Nope, somebody else removed user
+            user.sync_pds = True
+            user.save(force_update=True)
+            self.thread.last_post.set_checkpoint(self.request, 'removed', user)
+            self.thread.last_post.save(force_update=True)
+            self.request.messages.set_flash(Message(_('Selected participant was removed from thread.')), 'info', 'threads')
+            return self.retreat_redirect()
+        except User.DoesNotExist:
+            self.request.messages.set_flash(Message(_('Requested thread participant does not exist.')), 'error', 'threads')
+            return self.retreat_redirect()

+ 29 - 0
misago/apps/privatethreads/list.py

@@ -0,0 +1,29 @@
+from itertools import chain
+from django.utils.translation import ugettext as _
+from misago.apps.threadtype.list import ThreadsListBaseView, ThreadsListModeration
+from misago.models import Forum, Thread
+from misago.readstrackers import ThreadsTracker
+from misago.utils.pagination import make_pagination
+from misago.apps.privatethreads.mixins import TypeMixin
+
+class ThreadsListView(ThreadsListBaseView, ThreadsListModeration, TypeMixin):
+    def fetch_forum(self):
+        self.forum = Forum.objects.get(special='private_threads')
+
+    def threads_queryset(self):
+        return self.forum.thread_set.filter(participants__id=self.request.user.pk).order_by('-last')
+
+    def fetch_threads(self):
+        qs_threads = self.threads_queryset()
+
+        # Add in first and last poster
+        if self.request.settings.avatars_on_threads_list:
+            qs_threads = qs_threads.prefetch_related('start_poster', 'last_poster')
+
+        self.count = qs_threads.count()
+        self.pagination = make_pagination(self.kwargs.get('page', 0), self.count, self.request.settings.threads_per_page)
+
+        tracker_forum = ThreadsTracker(self.request, self.forum)
+        for thread in qs_threads[self.pagination['start']:self.pagination['stop']]:
+            thread.is_read = tracker_forum.is_read(thread)
+            self.threads.append(thread)

+ 39 - 0
misago/apps/privatethreads/mixins.py

@@ -0,0 +1,39 @@
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from django.utils.translation import ugettext as _
+from misago.acl.exceptions import ACLError404
+
+class TypeMixin(object):
+    type_prefix = 'private_thread'
+
+    def check_permissions(self):
+        try:
+            if self.thread.pk:
+                if not self.request.user in self.thread.participants.all():
+                    raise ACLError404()
+        except AttributeError:
+            pass
+
+    def invite_users(self, users):
+        sync_last_post = False
+        for user in users:
+            if not user in self.thread.participants.all():
+                self.thread.participants.add(user)
+                user.email_user(self.request, 'private_thread_invite', _("You've been invited to private thread \"%(thread)s\" by %(user)s") % {'thread': self.thread.name, 'user': self.request.user.username}, {'author': self.request.user, 'thread': self.thread})
+                if self.action == 'new_reply':
+                    self.thread.last_post.set_checkpoint(self.request, 'invited', user)
+        if sync_last_post:
+            self.thread.last_post.save(force_update=True)
+
+    def force_stats_sync(self):
+        self.thread.participants.exclude(id=self.request.user.id).update(sync_pds=True)
+                
+    def whitelist_mentions(self):
+        participants = self.thread.participants.all()
+        mentioned = self.post.mentions.all()
+        for user in self.md.mentions:
+            if user not in participants and user not in mentioned:
+                self.post.mentioned.add(user)
+
+    def threads_list_redirect(self):
+        return redirect(reverse('private_threads'))

+ 85 - 0
misago/apps/privatethreads/posting.py

@@ -0,0 +1,85 @@
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from django.utils.translation import ugettext as _
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.apps.threadtype.posting import NewThreadBaseView, EditThreadBaseView, NewReplyBaseView, EditReplyBaseView
+from misago.messages import Message
+from misago.models import Forum, Thread, Post, User
+from misago.apps.privatethreads.forms import (NewThreadForm, EditThreadForm,
+                                              NewReplyForm, EditReplyForm)
+from misago.apps.privatethreads.mixins import TypeMixin
+
+class NewThreadView(NewThreadBaseView, TypeMixin):
+    form_type = NewThreadForm
+
+    def set_forum_context(self):
+        self.forum = Forum.objects.get(special='private_threads')
+
+    def form_initial_data(self):
+        if self.kwargs.get('user'):
+            try:
+                user = User.objects.get(id=self.kwargs.get('user'))
+                acl = user.acl(self.request)
+                if not acl.private_threads.can_participate():
+                    raise ACLError403(_("This member can not participate in private threads."))
+                if (not self.request.acl.private_threads.can_invite_ignoring() and
+                        not user.allow_pd_invite(self.request.user)):
+                    raise ACLError403(_('%(user)s restricts who can invite him to private threads.') % {'user': user.username})
+                return {'invite_users': user.username}
+            except User.DoesNotExist:
+                raise ACLError404()
+        return {}
+
+    def after_form(self, form):
+        self.thread.participants.add(self.request.user)
+        self.invite_users(form.invite_users)
+        self.whitelist_mentions()
+        self.force_stats_sync()
+
+    def response(self):
+        if self.post.moderated:
+            self.request.messages.set_flash(Message(_("New thread has been posted. It will be hidden from other members until moderator reviews it.")), 'success', 'threads')
+        else:
+            self.request.messages.set_flash(Message(_("New thread has been posted.")), 'success', 'threads')
+        return redirect(reverse('private_thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % self.post.pk))
+
+
+class EditThreadView(EditThreadBaseView, TypeMixin):
+    form_type = EditThreadForm
+
+    def after_form(self, form):
+        self.whitelist_mentions()
+    
+    def response(self):
+        self.request.messages.set_flash(Message(_("Your thread has been edited.")), 'success', 'threads_%s' % self.post.pk)
+        return redirect(reverse('private_thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % self.post.pk))
+
+
+class NewReplyView(NewReplyBaseView, TypeMixin):
+    form_type = NewReplyForm
+
+    def after_form(self, form):
+        try:
+            self.invite_users(form.invite_users)
+        except AttributeError:
+            pass
+        self.whitelist_mentions()
+        self.force_stats_sync()
+
+    def response(self):
+        if self.post.moderated:
+            self.request.messages.set_flash(Message(_("Your reply has been posted. It will be hidden from other members until moderator reviews it.")), 'success', 'threads_%s' % self.post.pk)
+        else:
+            self.request.messages.set_flash(Message(_("Your reply has been posted.")), 'success', 'threads_%s' % self.post.pk)
+        return self.redirect_to_post(self.post)
+
+
+class EditReplyView(EditReplyBaseView, TypeMixin):
+    form_type = EditReplyForm
+
+    def after_form(self, form):
+        self.whitelist_mentions()
+
+    def response(self):
+        self.request.messages.set_flash(Message(_("Your reply has been changed.")), 'success', 'threads_%s' % self.post.pk)
+        return self.redirect_to_post(self.post)

+ 57 - 0
misago/apps/privatethreads/thread.py

@@ -0,0 +1,57 @@
+from django.utils.translation import ugettext as _
+from misago.apps.threadtype.thread import ThreadBaseView, ThreadModeration, PostsModeration
+from misago.forms import FormFields
+from misago.models import Forum, Thread
+from misago.apps.privatethreads.mixins import TypeMixin
+from misago.apps.privatethreads.forms import InviteMemberForm
+
+class ThreadView(ThreadBaseView, ThreadModeration, PostsModeration, TypeMixin):
+    def posts_actions(self):
+        acl = self.request.acl.threads.get_role(self.thread.forum_id)
+        actions = []
+        try:
+            if acl['can_move_threads_posts']:
+                actions.append(('merge', _('Merge posts into one')))
+            if acl['can_protect_posts']:
+                actions.append(('protect', _('Protect posts')))
+                actions.append(('unprotect', _('Remove posts protection')))
+            if acl['can_delete_posts']:
+                if self.thread.replies_deleted > 0:
+                    actions.append(('undelete', _('Undelete posts')))
+                actions.append(('soft', _('Soft delete posts')))
+            if acl['can_delete_posts'] == 2:
+                actions.append(('hard', _('Hard delete posts')))
+        except KeyError:
+            pass
+        return actions
+
+    def thread_actions(self):
+        acl = self.request.acl.threads.get_role(self.thread.forum_id)
+        actions = []
+        try:
+            if acl['can_close_threads']:
+                if self.thread.closed:
+                    actions.append(('open', _('Open this thread')))
+                else:
+                    actions.append(('close', _('Close this thread')))
+            if acl['can_delete_threads']:
+                if self.thread.deleted:
+                    actions.append(('undelete', _('Undelete this thread')))
+                else:
+                    actions.append(('soft', _('Soft delete this thread')))
+            if acl['can_delete_threads'] == 2:
+                actions.append(('hard', _('Hard delete this thread')))
+        except KeyError:
+            pass
+        return actions
+
+    def template_vars(self, context):
+        context['participants'] = self.thread.participants.all().prefetch_related('rank')
+        context['invite_form'] = FormFields(InviteMemberForm(request=self.request))
+        return context
+
+    def tracker_update(self, last_post):
+        super(ThreadView, self).tracker_update(last_post)
+        unread = self.tracker.unread_count(self.forum.thread_set.filter(participants__id=self.request.user.pk))
+        self.request.user.sync_unread_pds(unread)
+        self.request.user.save(force_update=True)

+ 32 - 0
misago/apps/privatethreads/urls.py

@@ -0,0 +1,32 @@
+from django.conf.urls import patterns, url
+
+urlpatterns = patterns('misago.apps.privatethreads',
+    url(r'^$', 'list.ThreadsListView', name="private_threads"),
+    url(r'^(?P<page>\d+)/$', 'list.ThreadsListView', name="private_threads"),
+    url(r'^start/$', 'posting.NewThreadView', name="private_thread_start"),
+    url(r'^start/(?P<username>\w+)-(?P<user>\d+)/$', 'posting.NewThreadView', name="private_thread_start_with"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/edit/$', 'posting.EditThreadView', name="private_thread_edit"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/reply/$', 'posting.NewReplyView', name="private_thread_reply"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<quote>\d+)/reply/$', 'posting.NewReplyView', name="private_thread_reply"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/edit/$', 'posting.EditReplyView', name="private_post_edit"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/$', 'thread.ThreadView', name="private_thread"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<page>\d+)/$', 'thread.ThreadView', name="private_thread"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/last/$', 'jumps.LastReplyView', name="private_thread_last"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/find-(?P<post>\d+)/$', 'jumps.FindReplyView', name="private_thread_find"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/new/$', 'jumps.NewReplyView', name="private_thread_new"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/show-hidden/$', 'jumps.ShowHiddenRepliesView', name="private_thread_show_hidden"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/$', 'jumps.WatchThreadView', name="private_thread_watch"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/email/$', 'jumps.WatchEmailThreadView', name="private_thread_watch_email"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/$', 'jumps.UnwatchThreadView', name="private_thread_unwatch"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/email/$', 'jumps.UnwatchEmailThreadView', name="private_thread_unwatch_email"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/invite/$', 'jumps.InviteUserView', name="private_thread_invite_user"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/remove/$', 'jumps.RemoveUserView', name="private_thread_remove_user"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/delete/$', 'delete.DeleteThreadView', name="private_thread_delete", kwargs={'mode': 'delete_thread'}),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/hide/$', 'delete.HideThreadView', name="private_thread_hide", kwargs={'mode': 'hide_thread'}),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/delete/$', 'delete.DeleteReplyView', name="private_post_delete", kwargs={'mode': 'delete_post'}),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/hide/$', 'delete.HideReplyView', name="private_post_hide", kwargs={'mode': 'hide_post'}),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/info/$', 'details.DetailsView', name="private_post_info"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/$', 'changelog.ChangelogView', name="private_thread_changelog"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/$', 'changelog.ChangelogDiffView', name="private_thread_changelog_diff"),
+    url(r'^(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/revert/$', 'changelog.ChangelogRevertView', name="private_thread_changelog_revert"),
+)

+ 0 - 0
misago/forumroles/migrations/__init__.py → misago/apps/profiles/__init__.py


+ 3 - 3
misago/profiles/decorators.py → misago/apps/profiles/decorators.py

@@ -1,9 +1,9 @@
 from functools import wraps
 from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
-from misago.utils import slugify
-from misago.views import error404
-from misago.users.models import User
+from misago.apps.errors import error404
+from misago.models import User
+from misago.utils.strings import slugify
 
 def profile_view(fallback='user'):
     def outer_decorator(f):

+ 0 - 0
misago/forums/__init__.py → misago/apps/profiles/details/__init__.py


+ 0 - 0
misago/profiles/details/profile.py → misago/apps/profiles/details/profile.py


+ 2 - 2
misago/profiles/details/urls.py → misago/apps/profiles/details/urls.py

@@ -3,12 +3,12 @@ from django.conf.urls import patterns, url
 def register_profile_urls(first=False):
     urlpatterns = []
     if first:
-        urlpatterns += patterns('misago.profiles.details.views',
+        urlpatterns += patterns('misago.apps.profiles.details.views',
             url(r'^$', 'details', name="user"),
             url(r'^$', 'details', name="user_details"),
         )
     else:
-        urlpatterns += patterns('misago.profiles.details.views',
+        urlpatterns += patterns('misago.apps.profiles.details.views',
             url(r'^details/$', 'details', name="user_details"),
         )
     return urlpatterns

+ 2 - 2
misago/profiles/details/views.py → misago/apps/profiles/details/views.py

@@ -1,5 +1,5 @@
-from misago.profiles.decorators import profile_view
-from misago.profiles.template import RequestContext
+from misago.apps.profiles.decorators import profile_view
+from misago.apps.profiles.template import RequestContext
 
 @profile_view('user_details')
 def details(request, user):

+ 0 - 0
misago/forums/management/__init__.py → misago/apps/profiles/followers/__init__.py


+ 0 - 0
misago/profiles/followers/profile.py → misago/apps/profiles/followers/profile.py


+ 2 - 2
misago/profiles/followers/urls.py → misago/apps/profiles/followers/urls.py

@@ -3,13 +3,13 @@ from django.conf.urls import patterns, url
 def register_profile_urls(first=False):
     urlpatterns = []
     if first:
-        urlpatterns += patterns('misago.profiles.followers.views',
+        urlpatterns += patterns('misago.apps.profiles.followers.views',
             url(r'^$', 'followers', name="user"),
             url(r'^$', 'followers', name="user_followers"),
             url(r'^(?P<page>\d+)/$', 'followers', name="user_followers"),
         )
     else:
-        urlpatterns += patterns('misago.profiles.followers.views',
+        urlpatterns += patterns('misago.apps.profiles.followers.views',
             url(r'^followers/$', 'followers', name="user_followers"),
             url(r'^followers/(?P<page>\d+)/$', 'followers', name="user_followers"),
         )

+ 4 - 3
misago/profiles/followers/views.py → misago/apps/profiles/followers/views.py

@@ -1,12 +1,13 @@
-from misago.profiles.decorators import profile_view
-from misago.profiles.template import RequestContext
-from misago.utils import make_pagination
+from misago.apps.profiles.decorators import profile_view
+from misago.apps.profiles.template import RequestContext
+from misago.utils.pagination import make_pagination
 
 @profile_view('user_followers')
 def followers(request, user, page=0):
     queryset = user.follows_set.order_by('username_slug')
     count = queryset.count()
     pagination = make_pagination(page, count, 24)
+    
     return request.theme.render_to_response('profiles/followers.html',
                                             context_instance=RequestContext(request, {
                                              'profile': user,

+ 0 - 0
misago/forums/management/commands/__init__.py → misago/apps/profiles/follows/__init__.py


+ 0 - 0
misago/profiles/follows/profile.py → misago/apps/profiles/follows/profile.py


+ 2 - 2
misago/profiles/follows/urls.py → misago/apps/profiles/follows/urls.py

@@ -3,13 +3,13 @@ from django.conf.urls import patterns, url
 def register_profile_urls(first=False):
     urlpatterns = []
     if first:
-        urlpatterns += patterns('misago.profiles.follows.views',
+        urlpatterns += patterns('misago.apps.profiles.follows.views',
             url(r'^$', 'follows', name="user"),
             url(r'^$', 'follows', name="user_follows"),
             url(r'^(?P<page>\d+)/$', 'follows', name="user_follows"),
         )
     else:
-        urlpatterns += patterns('misago.profiles.follows.views',
+        urlpatterns += patterns('misago.apps.profiles.follows.views',
             url(r'^follows/$', 'follows', name="user_follows"),
             url(r'^follows/(?P<page>\d+)/$', 'follows', name="user_follows"),
         )

+ 4 - 3
misago/profiles/follows/views.py → misago/apps/profiles/follows/views.py

@@ -1,9 +1,10 @@
-from misago.profiles.decorators import profile_view
-from misago.profiles.template import RequestContext
-from misago.utils import make_pagination
+from misago.apps.profiles.decorators import profile_view
+from misago.apps.profiles.template import RequestContext
+from misago.utils.pagination import make_pagination
 
 @profile_view('user_follows')
 def follows(request, user, page=0):
+    
     queryset = user.follows.order_by('username_slug')
     count = queryset.count()
     pagination = make_pagination(page, count, 24)

+ 1 - 2
misago/profiles/forms.py → misago/apps/profiles/forms.py

@@ -1,6 +1,5 @@
 from django import forms
 from misago.forms import Form
-    
-    
+
 class QuickFindUserForm(Form):
     username = forms.CharField()

+ 0 - 0
misago/forums/migrations/__init__.py → misago/apps/profiles/posts/__init__.py


+ 0 - 0
misago/profiles/posts/profile.py → misago/apps/profiles/posts/profile.py


+ 2 - 2
misago/profiles/posts/urls.py → misago/apps/profiles/posts/urls.py

@@ -3,13 +3,13 @@ from django.conf.urls import patterns, url
 def register_profile_urls(first=False):
     urlpatterns = []
     if first:
-        urlpatterns += patterns('misago.profiles.posts.views',
+        urlpatterns += patterns('misago.apps.profiles.posts.views',
             url(r'^$', 'posts', name="user"),
             url(r'^$', 'posts', name="user_posts"),
             url(r'^(?P<page>\d+)/$', 'posts', name="user_posts"),
         )
     else:
-        urlpatterns += patterns('misago.profiles.posts.views',
+        urlpatterns += patterns('misago.apps.profiles.posts.views',
             url(r'^posts/$', 'posts', name="user_posts"),
             url(r'^posts/(?P<page>\d+)/$', 'posts', name="user_posts"),
         )

+ 5 - 4
misago/profiles/posts/views.py → misago/apps/profiles/posts/views.py

@@ -1,10 +1,11 @@
-from misago.profiles.decorators import profile_view
-from misago.profiles.template import RequestContext
-from misago.utils import make_pagination
+from misago.apps.profiles.decorators import profile_view
+from misago.apps.profiles.template import RequestContext
+from misago.models import Forum
+from misago.utils.pagination import make_pagination
 
 @profile_view('user_posts')
 def posts(request, user, page=0):
-    queryset = user.post_set.filter(forum_id__in=request.acl.threads.get_readable_forums(request.acl)).filter(deleted=False).filter(moderated=False).select_related('thread', 'forum').order_by('-id')
+    queryset = user.post_set.filter(forum_id__in=Forum.objects.readable_forums(request.acl)).filter(deleted=False).filter(moderated=False).select_related('thread', 'forum').order_by('-id')
     count = queryset.count()
     pagination = make_pagination(page, count, 12)
     return request.theme.render_to_response('profiles/posts.html',

+ 1 - 1
misago/profiles/template.py → misago/apps/profiles/template.py

@@ -3,7 +3,7 @@ from django.conf import settings
 from django.template import RequestContext as DjangoRequestContext
 from django.utils import timezone
 from django.utils.importlib import import_module
-from misago.users.models import User
+from misago.models import User
 
 def RequestContext(request, context=None):
     if not context:

+ 0 - 0
misago/heartbeat/__init__.py → misago/apps/profiles/threads/__init__.py


+ 0 - 0
misago/profiles/threads/profile.py → misago/apps/profiles/threads/profile.py


+ 2 - 2
misago/profiles/threads/urls.py → misago/apps/profiles/threads/urls.py

@@ -3,13 +3,13 @@ from django.conf.urls import patterns, url
 def register_profile_urls(first=False):
     urlpatterns = []
     if first:
-        urlpatterns += patterns('misago.profiles.threads.views',
+        urlpatterns += patterns('misago.apps.profiles.threads.views',
             url(r'^$', 'threads', name="user"),
             url(r'^$', 'threads', name="user_threads"),
             url(r'^(?P<page>\d+)/$', 'threads', name="user_threads"),
         )
     else:
-        urlpatterns += patterns('misago.profiles.threads.views',
+        urlpatterns += patterns('misago.apps.profiles.threads.views',
             url(r'^threads/$', 'threads', name="user_threads"),
             url(r'^threads/(?P<page>\d+)/$', 'threads', name="user_threads"),
         )

+ 6 - 4
misago/profiles/threads/views.py → misago/apps/profiles/threads/views.py

@@ -1,12 +1,14 @@
-from misago.profiles.decorators import profile_view
-from misago.profiles.template import RequestContext
-from misago.utils import make_pagination
+from misago.apps.profiles.decorators import profile_view
+from misago.apps.profiles.template import RequestContext
+from misago.models import Forum
+from misago.utils.pagination import make_pagination
 
 @profile_view('user_threads')
 def threads(request, user, page=0):
-    queryset = user.thread_set.filter(forum_id__in=request.acl.threads.get_readable_forums(request.acl)).filter(deleted=False).filter(moderated=False).select_related('start_post', 'forum').order_by('-id')
+    queryset = user.thread_set.filter(forum_id__in=Forum.objects.readable_forums(request.acl)).filter(deleted=False).filter(moderated=False).select_related('start_post', 'forum').order_by('-id')
     count = queryset.count()
     pagination = make_pagination(page, count, 12)
+    
     return request.theme.render_to_response('profiles/threads.html',
                                             context_instance=RequestContext(request, {
                                              'profile': user,

+ 4 - 4
misago/profiles/urls.py → misago/apps/profiles/urls.py

@@ -2,7 +2,7 @@ from django.conf import settings
 from django.conf.urls import patterns, include, url
 from django.utils.importlib import import_module
 
-urlpatterns = patterns('misago.profiles.views',
+urlpatterns = patterns('misago.apps.profiles.views',
     url(r'^$', 'list', name="users"),
     url(r'^(?P<page>[0-9]+)/$', 'list', name="users"),
 )
@@ -19,7 +19,7 @@ for extension in settings.PROFILE_EXTENSIONS:
     except AttributeError:
         pass
 
-urlpatterns += patterns('misago.profiles.views',
-    url(r'^(?P<rank_slug>(\w|-)+)/$', 'list', name="users"),
-    url(r'^(?P<rank_slug>(\w|-)+)/(?P<page>[0-9]+)/$', 'list', name="users"),
+urlpatterns += patterns('misago.apps.profiles.views',
+    url(r'^(?P<slug>(\w|-)+)/$', 'list', name="users"),
+    url(r'^(?P<slug>(\w|-)+)/(?P<page>[0-9]+)/$', 'list', name="users"),
 )

+ 11 - 9
misago/profiles/views.py → misago/apps/profiles/views.py

@@ -1,26 +1,28 @@
 from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
 from django.template import RequestContext
+from misago.apps.errors import error403, error404
 from misago.forms import FormFields
 from misago.messages import Message
-from misago.profiles.forms import QuickFindUserForm
-from misago.ranks.models import Rank
-from misago.users.models import User
-from misago.utils import slugify, make_pagination
-from misago.views import error403, error404
+from misago.models import Rank, User
+from misago.utils.strings import slugify
+from misago.utils.pagination import make_pagination
+from misago.apps.profiles.forms import QuickFindUserForm
 
-def list(request, rank_slug=None, page=1):
+def list(request, slug=None, page=1):
     ranks = Rank.objects.filter(as_tab=1).order_by('order')
 
     # Find active rank
     default_rank = False
     active_rank = None
-    if rank_slug:
+    if slug:
         for rank in ranks:
-            if rank.name_slug == rank_slug:
+            if rank.slug == slug:
                 active_rank = rank
         if not active_rank:
             return error404(request)
+        if ranks and active_rank.slug == ranks[0].slug:
+            return redirect(reverse('users'))
     elif ranks:
         default_rank = True
         active_rank = ranks[0]
@@ -69,7 +71,7 @@ def list(request, rank_slug=None, page=1):
         if active_rank:
             users = User.objects.filter(rank=active_rank)
             items_total = users.count()
-            pagination = make_pagination(page, items_total, 4)
+            pagination = make_pagination(page, items_total, request.settings['profiles_per_list'])
             users = users.order_by('username_slug')[pagination['start']:pagination['stop']]
 
     return request.theme.render_to_response('profiles/list.html',

+ 23 - 0
misago/apps/readall.py

@@ -0,0 +1,23 @@
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from django.template import RequestContext
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from misago.decorators import block_guest, check_csrf
+from misago.messages import Message
+from misago.models import ForumRead, ThreadRead
+
+@block_guest
+@check_csrf
+def read_all(request):
+    ForumRead.objects.filter(user=request.user).delete()
+    ThreadRead.objects.filter(user=request.user).delete()
+    now = timezone.now()
+    bulk = []
+    for forum in request.acl.forums.known_forums():
+        new_record = ForumRead(user=request.user, forum_id=forum, updated=now, cleared=now)
+        bulk.append(new_record)
+    if bulk:
+        ForumRead.objects.bulk_create(bulk)
+    request.messages.set_flash(Message(_("All forums have been marked as read.")), 'success')
+    return redirect(reverse('index'))

+ 21 - 0
misago/apps/redirect.py

@@ -0,0 +1,21 @@
+from django.shortcuts import redirect as django_redirect
+from django.utils.translation import ugettext as _
+from misago.apps.errors import error403, error404
+from misago.models import Forum
+
+def redirect(request, forum, slug):
+    if not request.acl.forums.can_see(forum):
+        return error404(request)
+    try:
+        forum = Forum.objects.get(pk=forum, type='redirect')
+        if not request.acl.forums.can_browse(forum):
+            return error403(request, _("You don't have permission to follow this redirect."))
+        redirects_tracker = request.session.get('redirects', [])
+        if forum.pk not in redirects_tracker:
+            redirects_tracker.append(forum.pk)
+            request.session['redirects'] = redirects_tracker
+            forum.redirects += 1
+            forum.save(force_update=True)
+        return django_redirect(forum.redirect)
+    except Forum.DoesNotExist:
+        return error404(request)

+ 0 - 0
misago/monitor/__init__.py → misago/apps/register/__init__.py


+ 6 - 8
misago/register/forms.py → misago/apps/register/forms.py

@@ -1,12 +1,10 @@
 from django import forms
 from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext_lazy as _
-from misago.forms import Form
-from misago import captcha
-from misago.timezones import tzlist
-from misago.users.models import User
-from misago.users.validators import validate_username, validate_password, validate_email
-
+from misago.forms import Form, QACaptchaField, ReCaptchaField
+from misago.models import User
+from misago.utils.timezones import tzlist
+from misago.validators import validate_username, validate_password, validate_email
 
 class UserRegisterForm(Form):
     username = forms.CharField(max_length=15)
@@ -14,8 +12,8 @@ class UserRegisterForm(Form):
     email_rep = forms.EmailField(max_length=255)
     password = forms.CharField(max_length=255,widget=forms.PasswordInput)
     password_rep = forms.CharField(max_length=255,widget=forms.PasswordInput)
-    captcha_qa = captcha.QACaptchaField()
-    recaptcha = captcha.ReCaptchaField()
+    captcha_qa = QACaptchaField()
+    recaptcha = ReCaptchaField()
     accept_tos = forms.BooleanField(required=True,error_messages={'required': _("Acceptation of board ToS is mandatory for membership.")})
     
     validate_repeats = (('email', 'email_rep'), ('password', 'password_rep'))

+ 6 - 10
misago/register/views.py → misago/apps/register/views.py

@@ -3,17 +3,13 @@ from django.shortcuts import redirect
 from django.template import RequestContext
 from django.utils import timezone
 from django.utils.translation import ugettext as _
-from misago.banning.decorators import block_banned
-from misago.bruteforce.decorators import block_jammed
-from misago.bruteforce.models import SignInAttempt
-from misago.crawlers.decorators import block_crawlers
-from misago.forms.layouts import FormLayout
+from misago.auth import sign_user_in
+from misago.decorators import block_authenticated, block_banned, block_crawlers, block_jammed
+from misago.forms import FormLayout
 from misago.messages import Message
-from misago.authn.decorators import block_authenticated
-from misago.authn.methods import sign_user_in
-from misago.register.forms import UserRegisterForm
-from misago.users.models import User
-from misago.views import redirect_message
+from misago.models import SignInAttempt, User
+from misago.utils.views import redirect_message
+from misago.apps.register.forms import UserRegisterForm
 
 @block_crawlers
 @block_banned

+ 0 - 0
misago/monitor/migrations/__init__.py → misago/apps/reports/__init__.py


+ 2 - 0
misago/apps/reports/mixins.py

@@ -0,0 +1,2 @@
+class TypeMixin(object):
+    templates_prefix = 'reports'

+ 6 - 0
misago/apps/reports/urls.py

@@ -0,0 +1,6 @@
+from django.conf.urls import patterns, url
+
+urlpatterns = patterns('misago.apps.reports.views',
+    url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', 'ThreadsListView', name="reports"),
+    url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/(?P<page>\d+)/$', 'ThreadsView', name="reports"),
+)

+ 0 - 0
misago/newsfeed/__init__.py → misago/apps/resetpswd/__init__.py


+ 4 - 6
misago/resetpswd/forms.py → misago/apps/resetpswd/forms.py

@@ -2,15 +2,13 @@ import hashlib
 from django import forms
 from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext_lazy as _
-from misago.forms import Form
-from misago import captcha
-from misago.users.models import User
-    
+from misago.forms import Form, QACaptchaField, ReCaptchaField
+from misago.models import User
     
 class UserResetPasswordForm(Form):
     email = forms.EmailField(max_length=255)
-    captcha_qa = captcha.QACaptchaField()
-    recaptcha = captcha.ReCaptchaField()
+    captcha_qa = QACaptchaField()
+    recaptcha = ReCaptchaField()
     error_source = 'email'
     
     layout = [

+ 1 - 1
misago/resetpswd/urls.py → misago/apps/resetpswd/urls.py

@@ -1,6 +1,6 @@
 from django.conf.urls import patterns, url
 
-urlpatterns = patterns('misago.resetpswd.views',
+urlpatterns = patterns('misago.apps.resetpswd.views',
     url(r'^$', 'form', name="forgot_password"),
     url(r'^(?P<username>[a-z0-9]+)-(?P<user>\d+)/(?P<token>[a-zA-Z0-9]+)/$', 'reset', name="reset_password"),
 )

+ 11 - 16
misago/resetpswd/views.py → misago/apps/resetpswd/views.py

@@ -1,18 +1,13 @@
 from django.template import RequestContext
 from django.utils.translation import ugettext as _
-from misago.banning.models import check_ban
-from misago.banning.decorators import block_banned
-from misago.banning.views import error_banned
-from misago.bruteforce.decorators import block_jammed
-from misago.crawlers.decorators import block_crawlers
-from misago.forms.layouts import FormLayout
+from misago.apps.errors import error404, error_banned
+from misago.decorators import block_authenticated, block_banned, block_crawlers, block_jammed
+from misago.forms import FormLayout
 from misago.messages import Message
-from misago.authn.decorators import block_authenticated
-from misago.resetpswd.forms import UserResetPasswordForm
-from misago.users.models import User
-from misago.views import redirect_message, error404
-from misago.utils import get_random_string
-
+from misago.models import Ban, Session, Token, User
+from misago.utils.strings import random_string
+from misago.utils.views import redirect_message
+from misago.apps.resetpswd.forms import UserResetPasswordForm
 
 @block_crawlers
 @block_banned
@@ -26,14 +21,14 @@ def form(request):
         
         if form.is_valid():
             user = form.found_user
-            user_ban = check_ban(username=user.username, email=user.email)
+            user_ban = Ban.objects.check_ban(username=user.username, email=user.email)
             
             if user_ban:
                 return error_banned(request, user, user_ban)
             elif user.activation != User.ACTIVATION_NONE:
                 return redirect_message(request, Message(_("%(username)s, your account has to be activated in order for you to be able to request new password.") % {'username': user.username}), 'info')
             
-            user.token = get_random_string(12)
+            user.token = random_string(12)
             user.save(force_update=True)
             user.email_user(
                             request,
@@ -61,7 +56,7 @@ def reset(request, username="", user="0", token=""):
     user = int(user)
     try:
         user = User.objects.get(pk=user)
-        user_ban = check_ban(username=user.username, email=user.email)
+        user_ban = Ban.objects.check_ban(username=user.username, email=user.email)
         
         if user_ban:
             return error_banned(request, user, user_ban)
@@ -72,7 +67,7 @@ def reset(request, username="", user="0", token=""):
         if not token or not user.token or user.token != token:
             return redirect_message(request, Message(_("%(username)s, request confirmation link is invalid. Please request new confirmation link.") % {'username': user.username}), 'error')
         
-        new_password = get_random_string(6)
+        new_password = random_string(6)
         user.token = None
         user.set_password(new_password)
         user.save(force_update=True)

+ 0 - 0
misago/newsletters/__init__.py → misago/apps/signin/__init__.py


+ 1 - 2
misago/authn/forms.py → misago/apps/signin/forms.py

@@ -22,8 +22,7 @@ class SignInForm(Form):
               ]
 
     def __init__(self, *args, **kwargs):
-        show_remember_me = kwargs['show_remember_me']
-        del kwargs['show_remember_me']
+        show_remember_me = kwargs.pop('show_remember_me')
 
         super(SignInForm, self).__init__(*args, **kwargs)
         if not show_remember_me:

+ 2 - 2
misago/authn/urls.py → misago/apps/signin/urls.py

@@ -1,13 +1,13 @@
 from django.conf.urls import patterns, url
 from misago.admin import ADMIN_PATH
 
-urlpatterns = patterns('misago.authn.views',
+urlpatterns = patterns('misago.apps.signin.views',
     url(r'^signin/$', 'signin', name="sign_in"),
     url(r'^signout/$', 'signout', name="sign_out"),
 )
 
 # Include admin patterns
 if ADMIN_PATH:
-    urlpatterns += patterns('misago.authn.views',
+    urlpatterns += patterns('misago.apps.signin.views',
         url(r'^' + ADMIN_PATH + 'signout/$', 'signout', name="admin_sign_out"),
     )

+ 10 - 14
misago/authn/views.py → misago/apps/signin/views.py

@@ -4,19 +4,15 @@ from django.template import RequestContext
 from django.utils import timezone
 from django.utils.translation import ugettext as _
 from misago.admin import site
-from misago.crawlers.decorators import block_crawlers
-from misago.csrf.decorators import check_csrf
-from misago.banning.decorators import block_banned
-from misago.forms.layouts import FormLayout
+from misago.forms import FormLayout
 from misago.messages import Message
-import misago.authn.methods as auth
-from misago.authn.decorators import block_authenticated, block_guest
-from misago.authn.forms import SignInForm
-from misago.authn.methods import AuthException, auth_admin, auth_forum, sign_user_in
-from misago.bruteforce.decorators import block_jammed
-from misago.bruteforce.models import SignInAttempt
-from misago.sessions.models import Token
-from misago.utils import get_random_string
+import misago.auth as auth
+from misago.auth import AuthException, auth_admin, auth_forum, sign_user_in
+from misago.decorators import (block_authenticated, block_banned, block_crawlers,
+                            block_guest, block_jammed, check_csrf)
+from misago.models import SignInAttempt, Token
+from misago.utils.strings import random_string
+from misago.apps.signin.forms import SignInForm
 
 @block_crawlers
 @block_banned
@@ -56,7 +52,7 @@ def signin(request):
                 remember_me_token = False
 
                 if not request.firewall.admin and request.settings['remember_me_allow'] and form.cleaned_data['user_remember_me']:
-                    remember_me_token = get_random_string(42)
+                    remember_me_token = random_string(42)
                     remember_me = Token(
                                         id=remember_me_token,
                                         user=user,
@@ -65,7 +61,7 @@ def signin(request):
                                         )
                     remember_me.save()
                 if remember_me_token:
-                    request.cookie_jar.set('TOKEN', remember_me_token, True)
+                    request.cookiejar.set('TOKEN', remember_me_token, True)
                 request.messages.set_flash(Message(_("Welcome back, %(username)s!") % {'username': user.username}), 'success', 'security')
                 return redirect(success_redirect)
             except AuthException as e:

+ 0 - 0
misago/newsletters/migrations/__init__.py → misago/apps/threads/__init__.py


+ 15 - 0
misago/apps/threads/changelog.py

@@ -0,0 +1,15 @@
+from misago.apps.threadtype.changelog import (ChangelogChangesBaseView,
+                                              ChangelogDiffBaseView,
+                                              ChangelogRevertBaseView)
+from misago.apps.threads.mixins import TypeMixin
+
+class ChangelogView(ChangelogChangesBaseView, TypeMixin):
+    pass
+
+
+class ChangelogDiffView(ChangelogDiffBaseView, TypeMixin):
+    pass
+
+
+class ChangelogRevertView(ChangelogRevertBaseView, TypeMixin):
+    pass

+ 17 - 0
misago/apps/threads/delete.py

@@ -0,0 +1,17 @@
+from misago.apps.threadtype.delete import *
+from misago.apps.threads.mixins import TypeMixin
+
+class DeleteThreadView(DeleteThreadBaseView, TypeMixin):
+    pass
+
+
+class HideThreadView(HideThreadBaseView, TypeMixin):
+    pass
+
+
+class DeleteReplyView(DeleteReplyBaseView, TypeMixin):
+    pass
+
+
+class HideReplyView(HideReplyBaseView, TypeMixin):
+    pass

+ 9 - 0
misago/apps/threads/details.py

@@ -0,0 +1,9 @@
+from misago.apps.threadtype.details import DetailsBaseView, KarmaVotesBaseView
+from misago.apps.threads.mixins import TypeMixin
+
+class DetailsView(DetailsBaseView, TypeMixin):
+    pass
+
+
+class KarmaVotesView(KarmaVotesBaseView, TypeMixin):
+    pass

+ 49 - 0
misago/apps/threads/jumps.py

@@ -0,0 +1,49 @@
+from misago.apps.threadtype.jumps import *
+from misago.apps.threads.mixins import TypeMixin
+
+class LastReplyView(LastReplyBaseView, TypeMixin):
+    pass
+
+
+class FindReplyView(FindReplyBaseView, TypeMixin):
+    pass
+
+
+class NewReplyView(NewReplyBaseView, TypeMixin):
+    pass
+
+
+class FirstModeratedView(FirstModeratedBaseView, TypeMixin):
+    pass
+
+
+class FirstReportedView(FirstReportedBaseView, TypeMixin):
+    pass
+
+
+class ShowHiddenRepliesView(ShowHiddenRepliesBaseView, TypeMixin):
+    pass
+
+
+class WatchThreadView(WatchThreadBaseView, TypeMixin):
+    pass
+
+
+class WatchEmailThreadView(WatchEmailThreadBaseView, TypeMixin):
+    pass
+
+
+class UnwatchThreadView(UnwatchThreadBaseView, TypeMixin):
+    pass
+
+
+class UnwatchEmailThreadView(UnwatchEmailThreadBaseView, TypeMixin):
+    pass
+
+
+class UpvotePostView(UpvotePostBaseView, TypeMixin):
+    pass
+
+
+class DownvotePostView(DownvotePostBaseView, TypeMixin):
+    pass

+ 65 - 0
misago/apps/threads/list.py

@@ -0,0 +1,65 @@
+from itertools import chain
+from django.utils.translation import ugettext as _
+from misago.apps.threadtype.list import ThreadsListBaseView, ThreadsListModeration
+from misago.models import Forum, Thread
+from misago.readstrackers import ThreadsTracker
+from misago.utils.pagination import make_pagination
+from misago.apps.threads.mixins import TypeMixin
+
+class ThreadsListView(ThreadsListBaseView, ThreadsListModeration, TypeMixin):
+    def fetch_forum(self):
+        self.forum = Forum.objects.get(pk=self.kwargs.get('forum'), type='forum')
+
+    def threads_queryset(self):
+        announcements = self.request.acl.threads.filter_threads(self.request, self.forum, self.forum.thread_set).filter(weight=2).order_by('-pk')
+        threads = self.request.acl.threads.filter_threads(self.request, self.forum, self.forum.thread_set).filter(weight__lt=2).order_by('-last')
+
+        # Dont display threads by ignored users (unless they are important)
+        if self.request.user.is_authenticated():
+            ignored_users = self.request.user.ignored_users()
+            if ignored_users:
+                threads = threads.extra(where=["`threads_thread`.`start_poster_id` IS NULL OR `threads_thread`.`start_poster_id` NOT IN (%s)" % ','.join([str(i) for i in ignored_users])])
+
+        # Add in first and last poster
+        if self.request.settings.avatars_on_threads_list:
+            announcements = announcements.prefetch_related('start_poster', 'last_poster')
+            threads = threads.prefetch_related('start_poster', 'last_poster')
+
+        return announcements, threads
+
+    def fetch_threads(self):
+        qs_announcements, qs_threads = self.threads_queryset()
+        self.count = qs_threads.count()
+        self.pagination = make_pagination(self.kwargs.get('page', 0), self.count, self.request.settings.threads_per_page)
+
+        tracker_forum = ThreadsTracker(self.request, self.forum)
+        for thread in list(chain(qs_announcements, qs_threads[self.pagination['start']:self.pagination['stop']])):
+            thread.is_read = tracker_forum.is_read(thread)
+            self.threads.append(thread)
+
+    def threads_actions(self):
+        acl = self.request.acl.threads.get_role(self.forum)
+        actions = []
+        try:
+            if acl['can_approve']:
+                actions.append(('accept', _('Accept threads')))
+            if acl['can_pin_threads'] == 2:
+                actions.append(('annouce', _('Change to announcements')))
+            if acl['can_pin_threads'] > 0:
+                actions.append(('sticky', _('Change to sticky threads')))
+            if acl['can_pin_threads'] > 0:
+                actions.append(('normal', _('Change to standard thread')))
+            if acl['can_move_threads_posts']:
+                actions.append(('move', _('Move threads')))
+                actions.append(('merge', _('Merge threads')))
+            if acl['can_close_threads']:
+                actions.append(('open', _('Open threads')))
+                actions.append(('close', _('Close threads')))
+            if acl['can_delete_threads']:
+                actions.append(('undelete', _('Undelete threads')))
+                actions.append(('soft', _('Soft delete threads')))
+            if acl['can_delete_threads'] == 2:
+                actions.append(('hard', _('Hard delete threads')))
+        except KeyError:
+            pass
+        return actions

+ 8 - 0
misago/apps/threads/mixins.py

@@ -0,0 +1,8 @@
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+
+class TypeMixin(object):
+    type_prefix = 'thread'
+
+    def threads_list_redirect(self):
+        return redirect(reverse('forum', kwargs={'forum': self.forum.pk, 'slug': self.forum.slug}))

+ 39 - 0
misago/apps/threads/posting.py

@@ -0,0 +1,39 @@
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from django.utils.translation import ugettext as _
+from misago.apps.threadtype.posting import NewThreadBaseView, EditThreadBaseView, NewReplyBaseView, EditReplyBaseView
+from misago.messages import Message
+from misago.models import Forum, Thread, Post
+from misago.apps.threads.mixins import TypeMixin
+
+class NewThreadView(NewThreadBaseView, TypeMixin):
+    def set_forum_context(self):
+        self.forum = Forum.objects.get(pk=self.kwargs.get('forum'), type='forum')
+
+    def response(self):
+        if self.post.moderated:
+            self.request.messages.set_flash(Message(_("New thread has been posted. It will be hidden from other members until moderator reviews it.")), 'success', 'threads')
+        else:
+            self.request.messages.set_flash(Message(_("New thread has been posted.")), 'success', 'threads')
+        return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % self.post.pk))
+
+
+class EditThreadView(EditThreadBaseView, TypeMixin):
+    def response(self):
+        self.request.messages.set_flash(Message(_("Your thread has been edited.")), 'success', 'threads_%s' % self.post.pk)
+        return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % self.post.pk))
+
+
+class NewReplyView(NewReplyBaseView, TypeMixin):
+    def response(self):
+        if self.post.moderated:
+            self.request.messages.set_flash(Message(_("Your reply has been posted. It will be hidden from other members until moderator reviews it.")), 'success', 'threads_%s' % self.post.pk)
+        else:
+            self.request.messages.set_flash(Message(_("Your reply has been posted.")), 'success', 'threads_%s' % self.post.pk)
+        return self.redirect_to_post(self.post)
+
+
+class EditReplyView(EditReplyBaseView, TypeMixin):
+    def response(self):
+        self.request.messages.set_flash(Message(_("Your reply has been changed.")), 'success', 'threads_%s' % self.post.pk)
+        return self.redirect_to_post(self.post)

+ 61 - 0
misago/apps/threads/thread.py

@@ -0,0 +1,61 @@
+from django.utils.translation import ugettext as _
+from misago.apps.threadtype.thread import ThreadBaseView, ThreadModeration, PostsModeration
+from misago.models import Forum, Thread
+from misago.apps.threads.mixins import TypeMixin
+
+class ThreadView(ThreadBaseView, ThreadModeration, PostsModeration, TypeMixin):
+    def posts_actions(self):
+        acl = self.request.acl.threads.get_role(self.thread.forum_id)
+        actions = []
+        try:
+            if acl['can_approve'] and self.thread.replies_moderated > 0:
+                actions.append(('accept', _('Accept posts')))
+            if acl['can_move_threads_posts']:
+                actions.append(('merge', _('Merge posts into one')))
+                actions.append(('split', _('Split posts to new thread')))
+                actions.append(('move', _('Move posts to other thread')))
+            if acl['can_protect_posts']:
+                actions.append(('protect', _('Protect posts')))
+                actions.append(('unprotect', _('Remove posts protection')))
+            if acl['can_delete_posts']:
+                if self.thread.replies_deleted > 0:
+                    actions.append(('undelete', _('Undelete posts')))
+                actions.append(('soft', _('Soft delete posts')))
+            if acl['can_delete_posts'] == 2:
+                actions.append(('hard', _('Hard delete posts')))
+        except KeyError:
+            pass
+        return actions
+
+    def thread_actions(self):
+        acl = self.request.acl.threads.get_role(self.thread.forum_id)
+        actions = []
+        try:
+            if acl['can_approve'] and self.thread.moderated:
+                actions.append(('accept', _('Accept this thread')))
+            if acl['can_pin_threads'] == 2 and self.thread.weight < 2:
+                actions.append(('annouce', _('Change this thread to announcement')))
+            if acl['can_pin_threads'] > 0 and self.thread.weight != 1:
+                actions.append(('sticky', _('Change this thread to sticky')))
+            if acl['can_pin_threads'] > 0:
+                if self.thread.weight == 2:
+                    actions.append(('normal', _('Change this thread to normal')))
+                if self.thread.weight == 1:
+                    actions.append(('normal', _('Unpin this thread')))
+            if acl['can_move_threads_posts']:
+                actions.append(('move', _('Move this thread')))
+            if acl['can_close_threads']:
+                if self.thread.closed:
+                    actions.append(('open', _('Open this thread')))
+                else:
+                    actions.append(('close', _('Close this thread')))
+            if acl['can_delete_threads']:
+                if self.thread.deleted:
+                    actions.append(('undelete', _('Undelete this thread')))
+                else:
+                    actions.append(('soft', _('Soft delete this thread')))
+            if acl['can_delete_threads'] == 2:
+                actions.append(('hard', _('Hard delete this thread')))
+        except KeyError:
+            pass
+        return actions

+ 34 - 0
misago/apps/threads/urls.py

@@ -0,0 +1,34 @@
+from django.conf.urls import patterns, url
+
+urlpatterns = patterns('misago.apps.threads',
+    url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', 'list.ThreadsListView', name="forum"),
+    url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/(?P<page>\d+)/$', 'list.ThreadsListView', name="forum"),
+    url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/start/$', 'posting.NewThreadView', name="thread_start"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/edit/$', 'posting.EditThreadView', name="thread_edit"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/reply/$', 'posting.NewReplyView', name="thread_reply"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<quote>\d+)/reply/$', 'posting.NewReplyView', name="thread_reply"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/edit/$', 'posting.EditReplyView', name="post_edit"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/$', 'thread.ThreadView', name="thread"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<page>\d+)/$', 'thread.ThreadView', name="thread"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/last/$', 'jumps.LastReplyView', name="thread_last"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/find-(?P<post>\d+)/$', 'jumps.FindReplyView', name="thread_find"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/new/$', 'jumps.NewReplyView', name="thread_new"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/moderated/$', 'jumps.FirstModeratedView', name="thread_moderated"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/reported/$', 'jumps.FirstReportedView', name="thread_reported"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/show-hidden/$', 'jumps.ShowHiddenRepliesView', name="thread_show_hidden"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/$', 'jumps.WatchThreadView', name="thread_watch"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/email/$', 'jumps.WatchEmailThreadView', name="thread_watch_email"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/$', 'jumps.UnwatchThreadView', name="thread_unwatch"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/email/$', 'jumps.UnwatchEmailThreadView', name="thread_unwatch_email"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/upvote/$', 'jumps.UpvotePostView', name="post_upvote"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/downvote/$', 'jumps.DownvotePostView', name="post_downvote"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/delete/$', 'delete.DeleteThreadView', name="thread_delete", kwargs={'mode': 'delete_thread'}),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/hide/$', 'delete.HideThreadView', name="thread_hide", kwargs={'mode': 'hide_thread'}),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/delete/$', 'delete.DeleteReplyView', name="post_delete", kwargs={'mode': 'delete_post'}),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/hide/$', 'delete.HideReplyView', name="post_hide", kwargs={'mode': 'hide_post'}),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/info/$', 'details.DetailsView', name="post_info"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/votes/$', 'details.KarmaVotesView', name="post_votes"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/$', 'changelog.ChangelogView', name="thread_changelog"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/$', 'changelog.ChangelogDiffView', name="thread_changelog_diff"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/revert/$', 'changelog.ChangelogRevertView', name="thread_changelog_revert"),
+)

+ 0 - 0
misago/profiles/__init__.py → misago/apps/threadtype/__init__.py


+ 56 - 0
misago/apps/threadtype/base.py

@@ -0,0 +1,56 @@
+from django.core.urlresolvers import reverse
+from django.http import Http404
+from django.shortcuts import redirect
+from misago.models import Forum, Thread, Post
+from misago.utils.pagination import make_pagination
+
+class ViewBase(object):
+    def __new__(cls, request, **kwargs):
+        obj = super(ViewBase, cls).__new__(cls)
+        return obj(request, **kwargs)
+        
+    def set_forum_context(self):
+        pass
+
+    def set_thread_context(self):
+        self.thread = Thread.objects.get(pk=self.kwargs.get('thread'))
+        self.forum = self.thread.forum
+        self.proxy = Forum.objects.parents_aware_forum(self.forum)
+        self.request.acl.forums.allow_forum_view(self.forum)
+        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
+
+    def set_post_contex(self):
+        pass
+
+    def check_forum_type(self):
+        type_prefix = self.type_prefix
+        if type_prefix == 'thread':
+            type_prefix = 'root'
+        else:
+            type_prefix = '%ss' % type_prefix
+        try:
+            if self.parents[0].parent_id != Forum.objects.special_pk(type_prefix):
+                raise Http404()
+        except (AttributeError, IndexError):
+            if self.forum.special != type_prefix:
+                raise Http404()
+
+    def _check_permissions(self):
+        try:
+            self.check_permissions()
+        except AttributeError:
+            pass
+
+    def redirect_to_post(self, post):
+        pagination = make_pagination(0, self.request.acl.threads.filter_posts(self.request, self.thread, self.thread.post_set).filter(id__lte=post.pk).count(), self.request.settings.posts_per_page)
+        if pagination['total'] > 1:
+            return redirect(reverse(self.type_prefix, kwargs={'thread': self.thread.pk, 'slug': self.thread.slug, 'page': pagination['total']}) + ('#post-%s' % post.pk))
+        return redirect(reverse(self.type_prefix, kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % post.pk))
+
+    def template_vars(self, context):
+        return context
+
+    def retreat_redirect(self):
+        if self.request.POST.get('retreat'):
+            return redirect(self.request.POST.get('retreat'))
+        return redirect(reverse(self.type_prefix, kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}))

+ 41 - 40
misago/threads/views/changelog.py → misago/apps/threadtype/changelog.py

@@ -3,66 +3,70 @@ from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
 from django.template import RequestContext
 from django.utils.translation import ugettext as _
-from misago.acl.utils import ACLError403, ACLError404
-from misago.forums.models import Forum
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.apps.errors import error403, error404
 from misago.markdown import post_markdown
 from misago.messages import Message
-from misago.threads.models import Thread, Post, Change
-from misago.threads.views.base import BaseView
-from misago.views import error403, error404
-from misago.utils import make_pagination, slugify
+from misago.models import Forum, Thread, Post, Change
+from misago.utils.datesformats import reldate
+from misago.utils.strings import slugify
+from misago.utils.pagination import make_pagination
+from misago.apps.threadtype.base import ViewBase
 
-class ChangelogBaseView(BaseView):
-    def fetch_target(self, kwargs):
-        self.thread = Thread.objects.get(pk=kwargs['thread'])
+class ChangelogBaseView(ViewBase):
+    def fetch_target(self):
+        self.thread = Thread.objects.get(pk=self.kwargs.get('thread'))
         self.forum = self.thread.forum
-        self.proxy = Forum.objects.parents_aware_forum(self.forum)
         self.request.acl.forums.allow_forum_view(self.forum)
+        self.proxy = Forum.objects.parents_aware_forum(self.forum)
+        if self.forum.level:
+            self.parents = Forum.objects.forum_parents(self.forum.pk, True)
+        self.check_forum_type()
         self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
-        self.parents = Forum.objects.forum_parents(self.forum.pk, True)
-        self.post = Post.objects.select_related('user').get(pk=kwargs['post'], thread=self.thread.pk)
+        self.post = Post.objects.select_related('user').get(pk=self.kwargs.get('post'), thread=self.thread.pk)
         self.post.thread = self.thread
         self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
         self.request.acl.threads.allow_changelog_view(self.request.user, self.forum, self.post)
 
-    def dispatch(self, request, **kwargs):
-        raise NotImplementedError('ChangelogBaseView cannot be called directly. Did you forget to define custom "dispatch" method?')
-
     def __call__(self, request, **kwargs):
         self.request = request
+        self.kwargs = kwargs
         self.forum = None
         self.thread = None
         self.post = None
+        self.parents = []
         try:
-            self.fetch_target(kwargs)
+            self.fetch_target()
+            self._check_permissions()
             if not request.user.is_authenticated():
                 raise ACLError403(_("Guest, you have to sign-in in order to see posts changelogs."))
         except (Forum.DoesNotExist, Thread.DoesNotExist, Post.DoesNotExist, Change.DoesNotExist):
             return error404(self.request)
         except ACLError403 as e:
-            return error403(request, e.message)
+            return error403(request, e)
         except ACLError404 as e:
-            return error404(request, e.message)
-        return self.dispatch(request, **kwargs)
+            return error404(request, e)
+        return self.dispatch(request)
 
 
-class ChangelogView(ChangelogBaseView):
+class ChangelogChangesBaseView(ChangelogBaseView):
     def dispatch(self, request, **kwargs):
-        return request.theme.render_to_response('threads/changelog.html',
-                                                {
+        return request.theme.render_to_response('%ss/changelog.html' % self.type_prefix,
+                                                self.template_vars({
+                                                 'type_prefix': self.type_prefix,
                                                  'forum': self.forum,
                                                  'parents': self.parents,
                                                  'thread': self.thread,
                                                  'post': self.post,
                                                  'edits': self.post.change_set.prefetch_related('user').order_by('-id')
-                                                 },
+                                                 }),
                                                 context_instance=RequestContext(request))
 
 
-class ChangelogDiffView(ChangelogBaseView):
-    def fetch_target(self, kwargs):
-        super(ChangelogDiffView, self).fetch_target(kwargs)
-        self.change = self.post.change_set.get(pk=kwargs['change'])
+class ChangelogDiffBaseView(ChangelogBaseView):
+    def fetch_target(self):
+        super(ChangelogDiffBaseView, self).fetch_target()
+        self.change = self.post.change_set.get(pk=self.kwargs.get('change'))
 
     def dispatch(self, request, **kwargs):
         try:
@@ -74,8 +78,9 @@ class ChangelogDiffView(ChangelogBaseView):
         except IndexError:
             prev = None
         self.forum.closed = self.proxy.closed
-        return request.theme.render_to_response('threads/changelog_diff.html',
-                                                {
+        return request.theme.render_to_response('%ss/changelog_diff.html' % self.type_prefix,
+                                                self.template_vars({
+                                                 'type_prefix': self.type_prefix,
                                                  'forum': self.forum,
                                                  'parents': self.parents,
                                                  'thread': self.thread,
@@ -86,21 +91,21 @@ class ChangelogDiffView(ChangelogBaseView):
                                                  'message': request.messages.get_message('changelog'),
                                                  'l': 1,
                                                  'diff': difflib.ndiff(self.change.post_content.splitlines(), self.post.post.splitlines()),
-                                                 },
+                                                 }),
                                                 context_instance=RequestContext(request))
 
 
-class ChangelogRevertView(ChangelogDiffView):
-    def fetch_target(self, kwargs):
-        super(ChangelogDiffView, self).fetch_target(kwargs)
-        self.change = self.post.change_set.get(pk=kwargs['change'])
+class ChangelogRevertBaseView(ChangelogDiffBaseView):
+    def fetch_target(self):
+        super(ChangelogRevertBaseView, self).fetch_target()
+        self.change = self.post.change_set.get(pk=self.kwargs.get('change'))
         self.request.acl.threads.allow_revert(self.proxy, self.thread)
 
     def dispatch(self, request, **kwargs):
         if ((not self.change.thread_name_old or self.thread.name == self.change.thread_name_old)
             and (self.change.post_content == self.post.post)):
             request.messages.set_flash(Message(_("No changes to revert.")), 'error', 'changelog')
-            return redirect(reverse('changelog_diff', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug, 'post': self.post.pk, 'change': self.change.pk}))
+            return redirect(reverse('%s_changelog_diff' % self.type_prefix, kwargs={'thread': self.thread.pk, 'slug': self.thread.slug, 'post': self.post.pk, 'change': self.change.pk}))
 
         if self.change.thread_name_old and self.change.thread_name_old != self.thread.name:
             self.thread.name = self.change.thread_name_old
@@ -117,9 +122,5 @@ class ChangelogRevertView(ChangelogDiffView):
             md, self.post.post_preparsed = post_markdown(request, self.change.post_content)
             self.post.save(force_update=True)
 
-        from misago.template.templatetags.django2jinja import reldate
         request.messages.set_flash(Message(_("Post has been reverted to state from %(date)s.") % {'date': reldate(self.change.date).lower()}), 'success', 'threads_%s' % self.post.pk)
-        pagination = make_pagination(0, request.acl.threads.filter_posts(self.request, self.thread, self.thread.post_set).filter(id__lte=self.post.pk).count(), self.request.settings.posts_per_page)
-        if pagination['total'] > 1:
-            return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug, 'page': pagination['total']}) + ('#post-%s' % self.post.pk))
-        return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % self.post.pk))
+        return self.redirect_to_post(self.post)

+ 143 - 0
misago/apps/threadtype/delete.py

@@ -0,0 +1,143 @@
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.apps.errors import error403, error404
+from misago.messages import Message
+from misago.models import Forum, Thread, Post
+from misago.apps.threadtype.base import ViewBase
+
+class DeleteHideBaseView(ViewBase):
+    def _set_context(self):
+        self.thread = Thread.objects.get(pk=self.kwargs.get('thread'))
+        self.forum = self.thread.forum
+        self.proxy = Forum.objects.parents_aware_forum(self.forum)
+        if self.forum.level:
+            self.parents = Forum.objects.forum_parents(self.forum.pk)
+        self.check_forum_type()
+        self.request.acl.forums.allow_forum_view(self.forum)
+        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
+
+        if self.kwargs.get('post'):
+            self.post = self.thread.post_set.get(id=self.kwargs.get('post'))
+            self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
+
+        self.set_context()
+
+    def __call__(self, request, **kwargs):
+        self.request = request
+        self.kwargs = kwargs
+        self.parents = []
+        try:
+            self._set_context()
+            self._check_permissions()
+            self.delete()
+            self.message()
+            return self.response()
+        except (Forum.DoesNotExist, Thread.DoesNotExist, Post.DoesNotExist):
+            return error404(request)
+        except ACLError403 as e:
+            return error403(request, unicode(e))
+        except ACLError404 as e:
+            return error404(request, unicode(e))
+
+
+class DeleteThreadBaseView(DeleteHideBaseView):
+    def set_context(self):
+        self.request.acl.threads.allow_delete_thread(self.request.user, self.proxy,
+                                                     self.thread, self.thread.start_post, True)
+        # Assert we are not user trying to delete thread with replies
+        acl = self.request.acl.threads.get_role(self.thread.forum_id)
+        if not acl['can_delete_threads']:
+            if self.thread.post_set.exclude(user_id=self.request.user.id).count() > 0:
+                raise ACLError403(_("Somebody has already replied to this thread. You cannot delete it."))
+
+    def delete(self):
+        self.thread.delete()
+        self.forum.sync()
+        self.forum.save(force_update=True)
+
+    def message(self):
+        self.request.messages.set_flash(Message(_('Thread "%(thread)s" has been deleted.') % {'thread': self.thread.name}), 'success', 'threads')
+
+    def response(self):
+        return self.threads_list_redirect()
+
+
+class HideThreadBaseView(DeleteHideBaseView):
+    def set_context(self):
+        self.request.acl.threads.allow_delete_thread(self.request.user, self.proxy,
+                                                     self.thread, self.thread.start_post)
+        # Assert we are not user trying to delete thread with replies
+        acl = self.request.acl.threads.get_role(self.thread.forum_id)
+        if not acl['can_delete_threads']:
+            if self.thread.post_set.exclude(user_id=self.request.user.id).count() > 0:
+                raise ACLError403(_("Somebody has already replied to this thread. You cannot delete it."))
+
+    def delete(self):
+        self.thread.start_post.deleted = True
+        self.thread.start_post.save(force_update=True)
+        self.thread.last_post.set_checkpoint(self.request, 'deleted')
+        self.thread.last_post.save(force_update=True)
+        self.thread.sync()
+        self.thread.save(force_update=True)
+        self.forum.sync()
+        self.forum.save(force_update=True)
+
+    def message(self):
+        self.request.messages.set_flash(Message(_('Thread "%(thread)s" has been deleted.') % {'thread': self.thread.name}), 'success', 'threads')
+
+    def response(self):
+        if self.request.acl.threads.can_see_deleted_threads(self.thread.forum):
+            return redirect(reverse(self.type_prefix, kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}))
+        return self.threads_list_redirect()
+
+
+class DeleteReplyBaseView(DeleteHideBaseView):
+    def set_context(self):
+        self.request.acl.threads.allow_delete_post(self.request.user, self.forum,
+                                                   self.thread, self.post, True)
+        acl = self.request.acl.threads.get_role(self.thread.forum_id)
+        if not acl['can_delete_posts'] and self.thread.post_set.filter(id__gt=self.post.pk).count() > 0:
+            raise ACLError403(_("Somebody has already replied to this post, you cannot delete it."))
+
+    def delete(self):
+        self.post.delete()
+        self.thread.sync()
+        self.thread.save(force_update=True)
+        self.forum.sync()
+        self.forum.save(force_update=True)
+
+    def message(self):
+        self.request.messages.set_flash(Message(_("Selected Reply has been deleted.")), 'success', 'threads')
+
+    def response(self):
+        return redirect(reverse(self.type_prefix, kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}))
+
+
+class HideReplyBaseView(DeleteHideBaseView):
+    def set_context(self):
+        self.request.acl.threads.allow_delete_post(self.request.user, self.forum,
+                                                   self.thread, self.post)
+        acl = self.request.acl.threads.get_role(self.thread.forum_id)
+        if not acl['can_delete_posts'] and self.thread.post_set.filter(id__gt=self.post.pk).count() > 0:
+            raise ACLError403(_("Somebody has already replied to this post, you cannot delete it."))
+
+    def delete(self):
+        self.post.deleted = True
+        self.post.edit_date = timezone.now()
+        self.post.edit_user = self.request.user
+        self.post.edit_user_name = self.request.user.username
+        self.post.edit_user_slug = self.request.user.username_slug
+        self.post.save(force_update=True)
+        self.thread.sync()
+        self.thread.save(force_update=True)
+        self.forum.sync()
+        self.forum.save(force_update=True)
+
+    def message(self):
+        self.request.messages.set_flash(Message(_("Selected Reply has been deleted.")), 'success', 'threads_%s' % self.post.pk)
+
+    def response(self):
+        return self.redirect_to_post(self.post)

+ 72 - 0
misago/apps/threadtype/details.py

@@ -0,0 +1,72 @@
+from django.template import RequestContext
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.apps.errors import error403, error404
+from misago.models import Forum, Thread, Post
+from misago.apps.threadtype.base import ViewBase
+
+class ExtraBaseView(ViewBase):
+    def fetch_target(self):
+        self.thread = Thread.objects.get(pk=self.kwargs.get('thread'))
+        self.forum = self.thread.forum
+        self.proxy = Forum.objects.parents_aware_forum(self.forum)
+        self.request.acl.forums.allow_forum_view(self.forum)
+        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
+        if self.forum.level:
+            self.parents = Forum.objects.forum_parents(self.forum.pk, True)
+        self.check_forum_type()
+        self.post = Post.objects.select_related('user').get(pk=self.kwargs.get('post'), thread=self.thread.pk)
+        self.post.thread = self.thread
+        self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
+
+    def __call__(self, request, **kwargs):
+        self.request = request
+        self.kwargs = kwargs
+        self.forum = None
+        self.thread = None
+        self.post = None
+        self.parents = []
+        try:
+            self.fetch_target()
+            self.check_acl()
+            self._check_permissions()
+        except (Forum.DoesNotExist, Thread.DoesNotExist, Post.DoesNotExist):
+            return error404(self.request)
+        except ACLError403 as e:
+            return error403(request, e)
+        except ACLError404 as e:
+            return error404(request, e)
+        return self.response()
+
+
+class DetailsBaseView(ExtraBaseView):
+    def check_acl(self):
+        self.request.acl.users.allow_details_view()
+
+    def response(self):
+        return self.request.theme.render_to_response('%ss/details.html' % self.type_prefix,
+                                                     self.template_vars({
+                                                      'type_prefix': self.type_prefix,
+                                                      'forum': self.forum,
+                                                      'parents': self.parents,
+                                                      'thread': self.thread,
+                                                      'post': self.post,
+                                                     }),
+                                                     context_instance=RequestContext(self.request))
+
+
+class KarmaVotesBaseView(ExtraBaseView):
+    def check_acl(self):
+        self.request.acl.threads.allow_post_votes_view(self.forum)
+
+    def response(self):
+        return self.request.theme.render_to_response('%ss/karmas.html' % self.type_prefix,
+                                                     self.template_vars({
+                                                      'type_prefix': self.type_prefix,
+                                                      'forum': self.forum,
+                                                      'parents': self.parents,
+                                                      'thread': self.thread,
+                                                      'post': self.post,
+                                                      'upvotes': self.post.karma_set.filter(score=1),
+                                                      'downvotes': self.post.karma_set.filter(score=-1),
+                                                      }),
+                                                     context_instance=RequestContext(self.request))

+ 50 - 47
misago/threads/views/jumps.py → misago/apps/threadtype/jumps.py

@@ -1,21 +1,18 @@
 from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
-from django.template import RequestContext
 from django.utils import timezone
 from django.utils.translation import ugettext as _
-from misago.acl.utils import ACLError403, ACLError404
-from misago.authn.decorators import block_guest
-from misago.csrf.decorators import check_csrf
-from misago.forums.models import Forum
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.apps.errors import error403, error404
+from misago.decorators import block_guest, check_csrf
 from misago.messages import Message
-from misago.readstracker.trackers import ThreadsTracker
-from misago.threads.models import Thread, Post, Karma
-from misago.threads.views.base import BaseView
-from misago.views import error403, error404
-from misago.utils import make_pagination
-from misago.watcher.models import ThreadWatch
-
-class JumpView(BaseView):
+from misago.models import Forum, Thread, Post, Karma, WatchedThread
+from misago.readstrackers import ThreadsTracker
+from misago.utils.pagination import make_pagination
+from misago.utils.views import json_response
+from misago.apps.threadtype.base import ViewBase
+
+class JumpView(ViewBase):
     def fetch_thread(self, thread):
         self.thread = Thread.objects.get(pk=thread)
         self.forum = self.thread.forum
@@ -26,75 +23,74 @@ class JumpView(BaseView):
         self.post = self.thread.post_set.get(pk=post)
         self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
 
-    def redirect(self, post):
-        pagination = make_pagination(0, self.request.acl.threads.filter_posts(self.request, self.thread, self.thread.post_set.filter(date__lt=post.date)).count() + 1, self.request.settings.posts_per_page)
-        if pagination['total'] > 1:
-            return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug, 'page': pagination['total']}) + ('#post-%s' % post.pk))
-        return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % post.pk))
-
     def make_jump(self):
         raise NotImplementedError('JumpView cannot be called directly.')
 
     def __call__(self, request, slug=None, thread=None, post=None):
         self.request = request
+        self.parents = []
         try:
             self.fetch_thread(thread)
+            if self.forum.level:
+                self.parents = Forum.objects.forum_parents(self.forum.pk, True)
+            self.check_forum_type()
+            self._check_permissions()
             if post:
                 self.fetch_post(post)
             return self.make_jump()
         except (Thread.DoesNotExist, Post.DoesNotExist):
             return error404(self.request)
         except ACLError403 as e:
-            return error403(request, e.message)
+            return error403(request, e)
         except ACLError404 as e:
-            return error404(request, e.message)
+            return error404(request, e)
 
 
-class LastReplyView(JumpView):
+class LastReplyBaseView(JumpView):
     def make_jump(self):
-        return self.redirect(self.thread.post_set.order_by('-id')[:1][0])
+        return self.redirect_to_post(self.thread.post_set.order_by('-id')[:1][0])
 
 
-class FindReplyView(JumpView):
+class FindReplyBaseView(JumpView):
     def make_jump(self):
-        return self.redirect(self.post)
+        return self.redirect_to_post(self.post)
 
 
-class NewReplyView(JumpView):
+class NewReplyBaseView(JumpView):
     def make_jump(self):
         if not self.request.user.is_authenticated():
-            return self.redirect(self.thread.post_set.order_by('-id')[:1][0])
+            return self.redirect_to_post(self.thread.post_set.order_by('-id')[:1][0])
         tracker = ThreadsTracker(self.request, self.forum)
-        read_date = tracker.get_read_date(self.thread)
+        read_date = tracker.read_date(self.thread)
         post = self.thread.post_set.filter(date__gt=read_date).order_by('id')[:1]
         if not post:
-            return self.redirect(self.thread.post_set.order_by('-id')[:1][0])
-        return self.redirect(post[0])
+            return self.redirect_to_post(self.thread.post_set.order_by('-id')[:1][0])
+        return self.redirect_to_post(post[0])
 
 
-class FirstModeratedView(JumpView):
+class FirstModeratedBaseView(JumpView):
     def make_jump(self):
         if not self.request.acl.threads.can_approve(self.forum):
             raise ACLError404()
         try:
-            return self.redirect(
+            return self.redirect_to_post(
                 self.thread.post_set.get(moderated=True))
         except Post.DoesNotExist:
             return error404(self.request)
 
 
-class FirstReportedView(JumpView):
+class FirstReportedBaseView(JumpView):
     def make_jump(self):
         if not self.request.acl.threads.can_mod_posts(self.forum):
             raise ACLError404()
         try:
-            return self.redirect(
+            return self.redirect_to_post(
                 self.thread.post_set.get(reported=True))
         except Post.DoesNotExist:
             return error404(self.request)
 
 
-class ShowHiddenRepliesView(JumpView):
+class ShowHiddenRepliesBaseView(JumpView):
     def make_jump(self):
         @block_guest
         @check_csrf
@@ -103,11 +99,11 @@ class ShowHiddenRepliesView(JumpView):
             ignored_exclusions.append(self.thread.pk)
             request.session['unignore_threads'] = ignored_exclusions
             request.messages.set_flash(Message(_('Replies made to this thread by members on your ignore list have been revealed.')), 'success', 'threads')
-            return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}))
+            return redirect(reverse(self.type_prefix, kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}))
         return view(self.request)
 
 
-class WatchThreadView(JumpView):
+class WatchThreadBaseView(JumpView):
     def get_retreat(self):
         return redirect(self.request.POST.get('retreat', reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug})))
 
@@ -119,9 +115,9 @@ class WatchThreadView(JumpView):
         @check_csrf
         def view(request):
             try:
-                watcher = ThreadWatch.objects.get(user=request.user, thread=self.thread)
-            except ThreadWatch.DoesNotExist:
-                watcher = ThreadWatch()
+                watcher = WatchedThread.objects.get(user=request.user, thread=self.thread)
+            except WatchedThread.DoesNotExist:
+                watcher = WatchedThread()
                 watcher.user = request.user
                 watcher.forum = self.forum
                 watcher.thread = self.thread
@@ -135,7 +131,7 @@ class WatchThreadView(JumpView):
         return view(self.request)
 
 
-class WatchEmailThreadView(WatchThreadView):
+class WatchEmailThreadBaseView(WatchThreadBaseView):
     def update_watcher(self, request, watcher):
         watcher.email = True
         if watcher.pk:
@@ -144,7 +140,7 @@ class WatchEmailThreadView(WatchThreadView):
             request.messages.set_flash(Message(_('This thread has been added to your watched threads list. You will also receive e-mail with notification when somebody replies to it.')), 'success', 'threads')
 
 
-class UnwatchThreadView(WatchThreadView):
+class UnwatchThreadBaseView(WatchThreadBaseView):
     def update_watcher(self, request, watcher):
         watcher.deleted = True
         watcher.delete()
@@ -154,13 +150,13 @@ class UnwatchThreadView(WatchThreadView):
             request.messages.set_flash(Message(_('This thread has been removed from your watched threads list.')), 'success', 'threads')
 
 
-class UnwatchEmailThreadView(WatchThreadView):
+class UnwatchEmailThreadBaseView(WatchThreadBaseView):
     def update_watcher(self, request, watcher):
         watcher.email = False
         request.messages.set_flash(Message(_('You will no longer receive e-mails with notifications when somebody replies to this thread.')), 'success', 'threads')
 
 
-class UpvotePostView(JumpView):        
+class UpvotePostBaseView(JumpView):
     def make_jump(self):
         @block_guest
         @check_csrf
@@ -197,7 +193,6 @@ class UpvotePostView(JumpView):
             vote.ip = request.session.get_ip(request)
             vote.agent = request.META.get('HTTP_USER_AGENT')
             self.make_vote(request, vote)
-            request.messages.set_flash(Message(_('Your vote has been saved.')), 'success', 'threads_%s' % self.post.pk)
             if vote.pk:
                 vote.save(force_update=True)
             else:
@@ -224,7 +219,15 @@ class UpvotePostView(JumpView):
             request.user.save(force_update=True)
             if self.post.user_id:
                 self.post.user.save(force_update=True)
-            return self.redirect(self.post)
+            if request.is_ajax():
+                return json_response(request, {
+                                               'score_total': self.post.upvotes - self.post.downvotes,
+                                               'score_upvotes': self.post.upvotes,
+                                               'score_downvotes': self.post.downvotes,
+                                               'user_vote': vote.score,
+                                              })
+            request.messages.set_flash(Message(_('Your vote has been saved.')), 'success', 'threads_%s' % self.post.pk)
+            return self.redirect_to_post(self.post)
         return view(self.request)
     
     def check_acl(self, request):
@@ -234,7 +237,7 @@ class UpvotePostView(JumpView):
         vote.score = 1
 
 
-class DownvotePostView(UpvotePostView):
+class DownvotePostBaseView(UpvotePostBaseView):
     def check_acl(self, request):
         request.acl.threads.allow_post_downvote(self.forum)
     

+ 2 - 0
misago/apps/threadtype/list/__init__.py

@@ -0,0 +1,2 @@
+from misago.apps.threadtype.list.views import ThreadsListBaseView
+from misago.apps.threadtype.list.moderation import ThreadsListModeration

+ 89 - 0
misago/apps/threadtype/list/forms.py

@@ -0,0 +1,89 @@
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+from misago.forms import Form, ForumChoiceField
+from misago.models import Forum
+from misago.validators import validate_sluggable
+from misago.apps.threadtype.mixins import ValidateThreadNameMixin
+
+class MoveThreadsForm(Form):
+    error_source = 'new_forum'
+
+    def __init__(self, data=None, request=None, forum=None, *args, **kwargs):
+        self.forum = forum
+        super(MoveThreadsForm, self).__init__(data, request=request, *args, **kwargs)
+
+    def finalize_form(self):
+        self.fields['new_forum'] = ForumChoiceField(queryset=Forum.objects.get(special='root').get_descendants().filter(pk__in=self.request.acl.forums.acl['can_browse']))
+        self.layout = [
+                       [
+                        None,
+                        [
+                         ('new_forum', {'label': _("Move Threads to"), 'help_text': _("Select forum you want to move threads to.")}),
+                         ],
+                        ],
+                       ]
+
+    def clean_new_forum(self):
+        new_forum = self.cleaned_data['new_forum']
+        # Assert its forum and its not current forum
+        if new_forum.type != 'forum':
+            raise forms.ValidationError(_("This is not forum."))
+        if new_forum.pk == self.forum.pk:
+            raise forms.ValidationError(_("New forum is same as current one."))
+        return new_forum
+
+
+class MergeThreadsForm(Form, ValidateThreadNameMixin):
+    def __init__(self, data=None, request=None, threads=[], *args, **kwargs):
+        self.threads = threads
+        super(MergeThreadsForm, self).__init__(data, request=request, *args, **kwargs)
+
+    def finalize_form(self):
+        self.fields['new_forum'] = ForumChoiceField(queryset=Forum.objects.get(special='root').get_descendants().filter(pk__in=self.request.acl.forums.acl['can_browse']), initial=self.threads[0].forum)
+        self.fields['thread_name'] = forms.CharField(
+                                                     max_length=self.request.settings['thread_name_max'],
+                                                     initial=self.threads[0].name,
+                                                     validators=[validate_sluggable(
+                                                                                    _("Thread name must contain at least one alpha-numeric character."),
+                                                                                    _("Thread name is too long. Try shorter name.")
+                                                                                    )])
+        self.layout = [
+                       [
+                        None,
+                        [
+                         ('thread_name', {'label': _("Thread Name"), 'help_text': _("Name of new thread that will be created as result of merge.")}),
+                         ('new_forum', {'label': _("Thread Forum"), 'help_text': _("Select forum you want to put new thread in.")}),
+                         ],
+                        ],
+                       [
+                        _("Merge Order"),
+                        [
+                         ],
+                        ],
+                       ]
+
+        choices = []
+        for i, thread in enumerate(self.threads):
+            choices.append((str(i), i + 1))
+        for i, thread in enumerate(self.threads):
+            self.fields['thread_%s' % thread.pk] = forms.ChoiceField(choices=choices, initial=str(i))
+            self.layout[1][1].append(('thread_%s' % thread.pk, {'label': thread.name}))
+
+    def clean_new_forum(self):
+        new_forum = self.cleaned_data['new_forum']
+        # Assert its forum
+        if new_forum.type != 'forum':
+            raise forms.ValidationError(_("This is not forum."))
+        return new_forum
+
+    def clean(self):
+        cleaned_data = super(MergeThreadsForm, self).clean()
+        self.merge_order = {}
+        lookback = []
+        for thread in self.threads:
+            order = int(cleaned_data['thread_%s' % thread.pk])
+            if order in lookback:
+                raise forms.ValidationError(_("One or more threads have same position in merge order."))
+            lookback.append(order)
+            self.merge_order[order] = thread
+        return cleaned_data

+ 9 - 169
misago/threads/views/list.py → misago/apps/threadtype/list/moderation.py

@@ -1,141 +1,12 @@
-from django.core.urlresolvers import reverse
-from django.db.models import Q
-from django import forms
 from django.forms import ValidationError
-from django.shortcuts import redirect
 from django.template import RequestContext
-from django.utils import timezone
 from django.utils.translation import ugettext as _
-from misago.acl.utils import ACLError403, ACLError404
-from misago.forms import Form, FormLayout, FormFields
-from misago.forums.models import Forum
+from misago.forms import FormLayout
 from misago.messages import Message
-from misago.readstracker.trackers import ForumsTracker, ThreadsTracker
-from misago.threads.forms import MoveThreadsForm, MergeThreadsForm
-from misago.threads.models import Thread, Post
-from misago.threads.views.base import BaseView
-from misago.views import error403, error404
-from misago.utils import make_pagination, slugify
-
-class ThreadsView(BaseView):
-    def fetch_forum(self, forum):
-        self.forum = Forum.objects.get(pk=forum, type='forum')
-        self.proxy = Forum.objects.parents_aware_forum(self.forum)
-        self.request.acl.forums.allow_forum_view(self.forum)
-        self.parents = Forum.objects.forum_parents(self.forum.pk)
-        if self.forum.lft + 1 != self.forum.rght:
-            self.forum.subforums = Forum.objects.treelist(self.request.acl.forums, self.forum, tracker=ForumsTracker(self.request.user))
-        self.tracker = ThreadsTracker(self.request, self.forum)
-
-    def fetch_threads(self, page):
-        self.count = self.request.acl.threads.filter_threads(self.request, self.forum, Thread.objects.filter(forum=self.forum).filter(weight__lt=2)).count()
-        self.pagination = make_pagination(page, self.count, self.request.settings.threads_per_page)
-        self.threads = []
-        ignored_users = []
-        queryset_anno = Thread.objects.filter(Q(forum=Forum.objects.token_to_pk('annoucements')) | (Q(forum=self.forum) & Q(weight=2)))
-        queryset_threads = self.request.acl.threads.filter_threads(self.request, self.forum, Thread.objects.filter(forum=self.forum).filter(weight__lt=2)).order_by('-weight', '-last')
-        if self.request.user.is_authenticated():
-            ignored_users = self.request.user.ignored_users()
-            if ignored_users:
-                queryset_threads = queryset_threads.extra(where=["`threads_thread`.`start_poster_id` IS NULL OR `threads_thread`.`start_poster_id` NOT IN (%s)" % ','.join([str(i) for i in ignored_users])])
-        if self.request.settings.avatars_on_threads_list:
-            queryset_anno = queryset_anno.prefetch_related('start_poster', 'last_post')
-            queryset_threads = queryset_threads.prefetch_related('start_poster', 'last_poster')
-        for thread in queryset_anno:
-            self.threads.append(thread)
-        for thread in queryset_threads:
-            self.threads.append(thread)
-        if self.request.settings.threads_per_page < self.count:
-            self.threads = self.threads[self.pagination['start']:self.pagination['stop']]
-        for thread in self.threads:
-            thread.is_read = self.tracker.is_read(thread)
-            thread.last_poster_ignored = thread.last_poster_id in ignored_users
-
-    def get_thread_actions(self):
-        acl = self.request.acl.threads.get_role(self.forum)
-        actions = []
-        try:
-            if acl['can_approve']:
-                actions.append(('accept', _('Accept threads')))
-            if acl['can_pin_threads'] == 2:
-                actions.append(('annouce', _('Change to annoucements')))
-            if acl['can_pin_threads'] > 0:
-                actions.append(('sticky', _('Change to sticky threads')))
-            if acl['can_pin_threads'] > 0:
-                actions.append(('normal', _('Change to standard thread')))
-            if acl['can_move_threads_posts']:
-                actions.append(('move', _('Move threads')))
-                actions.append(('merge', _('Merge threads')))
-            if acl['can_close_threads']:
-                actions.append(('open', _('Open threads')))
-                actions.append(('close', _('Close threads')))
-            if acl['can_delete_threads']:
-                actions.append(('undelete', _('Undelete threads')))
-                actions.append(('soft', _('Soft delete threads')))
-            if acl['can_delete_threads'] == 2:
-                actions.append(('hard', _('Hard delete threads')))
-        except KeyError:
-            pass
-        return actions
-
-    def make_form(self):
-        self.form = None
-        list_choices = self.get_thread_actions();
-        if (not self.request.user.is_authenticated()
-            or not list_choices):
-            return
-
-        form_fields = {}
-        form_fields['list_action'] = forms.ChoiceField(choices=list_choices)
-        list_choices = []
-        for item in self.threads:
-            if item.forum_id == self.forum.pk:
-                list_choices.append((item.pk, None))
-        if not list_choices:
-            return
-        form_fields['list_items'] = forms.MultipleChoiceField(choices=list_choices, widget=forms.CheckboxSelectMultiple)
-        self.form = type('ThreadsViewForm', (Form,), form_fields)
-
-    def handle_form(self):
-        if self.request.method == 'POST':
-            self.form = self.form(self.request.POST, request=self.request)
-            if self.form.is_valid():
-                checked_items = []
-                posts = []
-                for thread in self.threads:
-                    if str(thread.pk) in self.form.cleaned_data['list_items'] and thread.forum_id == self.forum.pk:
-                        posts.append(thread.start_post_id)
-                        if thread.start_post_id != thread.last_post_id:
-                            posts.append(thread.last_post_id)
-                        checked_items.append(thread.pk)
-                if checked_items:
-                    if posts:
-                        for post in Post.objects.filter(id__in=posts).prefetch_related('user'):
-                            for thread in self.threads:
-                                if thread.start_post_id == post.pk:
-                                    thread.start_post = post
-                                if thread.last_post_id == post.pk:
-                                    thread.last_post = post
-                                if thread.start_post_id == post.pk or thread.last_post_id == post.pk:
-                                    break
-                    form_action = getattr(self, 'action_' + self.form.cleaned_data['list_action'])
-                    try:
-                        response = form_action(checked_items)
-                        if response:
-                            return response
-                        return redirect(self.request.path)
-                    except forms.ValidationError as e:
-                        self.message = Message(e.messages[0], 'error')
-                else:
-                    self.message = Message(_("You have to select at least one thread."), 'error')
-            else:
-                if 'list_action' in self.form.errors:
-                    self.message = Message(_("Action requested is incorrect."), 'error')
-                else:
-                    self.message = Message(form.non_field_errors()[0], 'error')
-        else:
-            self.form = self.form(request=self.request)
+from misago.models import Forum, Thread, Post
+from misago.apps.threadtype.list.forms import MoveThreadsForm, MergeThreadsForm
 
+class ThreadsListModeration(object):
     def action_accept(self, ids):
         accepted = 0
         last_posts = []
@@ -174,7 +45,7 @@ class ThreadsView(BaseView):
                 annouced.append(thread.pk)
         if annouced:
             Thread.objects.filter(id__in=annouced).update(weight=2)
-            self.request.messages.set_flash(Message(_('Selected threads have been turned into annoucements.')), 'success', 'threads')
+            self.request.messages.set_flash(Message(_('Selected threads have been turned into announcements.')), 'success', 'threads')
 
     def action_sticky(self, ids):
         acl = self.request.acl.threads.get_role(self.forum)
@@ -216,8 +87,9 @@ class ThreadsView(BaseView):
             self.message = Message(form.non_field_errors()[0], 'error')
         else:
             form = MoveThreadsForm(request=self.request, forum=self.forum)
-        return self.request.theme.render_to_response('threads/move_threads.html',
+        return self.request.theme.render_to_response('%ss/move_threads.html' % self.type_prefix,
                                                      {
+                                                      'type_prefix': self.type_prefix,
                                                       'message': self.message,
                                                       'forum': self.forum,
                                                       'parents': self.parents,
@@ -266,8 +138,9 @@ class ThreadsView(BaseView):
             self.message = Message(form.non_field_errors()[0], 'error')
         else:
             form = MergeThreadsForm(request=self.request, threads=threads)
-        return self.request.theme.render_to_response('threads/merge.html',
+        return self.request.theme.render_to_response(('%ss/merge.html' % self.type_prefix),
                                                      {
+                                                      'type_prefix': self.type_prefix,
                                                       'message': self.message,
                                                       'forum': self.forum,
                                                       'parents': self.parents,
@@ -357,36 +230,3 @@ class ThreadsView(BaseView):
             self.forum.sync()
             self.forum.save(force_update=True)
             self.request.messages.set_flash(Message(_('Selected threads have been deleted.')), 'success', 'threads')
-
-    def __call__(self, request, slug=None, forum=None, page=0):
-        self.request = request
-        self.pagination = None
-        self.parents = None
-        self.message = request.messages.get_message('threads')
-        try:
-            self.fetch_forum(forum)
-            self.fetch_threads(page)
-            self.make_form()
-            if self.form:
-                response = self.handle_form()
-                if response:
-                    return response
-        except Forum.DoesNotExist:
-            return error404(request)
-        except ACLError403 as e:
-            return error403(request, e.message)
-        except ACLError404 as e:
-            return error404(request, e.message)
-        # Merge proxy into forum
-        self.forum.closed = self.proxy.closed
-        return request.theme.render_to_response('threads/list.html',
-                                                {
-                                                 'message': self.message,
-                                                 'forum': self.forum,
-                                                 'parents': self.parents,
-                                                 'count': self.count,
-                                                 'list_form': FormFields(self.form).fields if self.form else None,
-                                                 'threads': self.threads,
-                                                 'pagination': self.pagination,
-                                                 },
-                                                context_instance=RequestContext(request));

+ 127 - 0
misago/apps/threadtype/list/views.py

@@ -0,0 +1,127 @@
+from django import forms
+from django.core.urlresolvers import reverse
+from django.forms import ValidationError
+from django.shortcuts import redirect
+from django.template import RequestContext
+from django.utils.translation import ugettext as _
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.apps.errors import error403, error404
+from misago.forms import Form, FormFields
+from misago.models import Forum, Thread, Post
+from misago.readstrackers import ForumsTracker
+from misago.apps.threadtype.base import ViewBase
+
+class ThreadsListBaseView(ViewBase):
+    template = 'list'
+
+    def _fetch_forum(self):
+        self.fetch_forum()
+        self.proxy = Forum.objects.parents_aware_forum(self.forum)
+        self.request.acl.forums.allow_forum_view(self.forum)
+        if self.forum.level:
+            self.parents = Forum.objects.forum_parents(self.forum.pk)
+        self.check_forum_type()
+        if self.forum.lft + 1 != self.forum.rght:
+            self.forum.subforums = Forum.objects.treelist(self.request.acl.forums, self.forum, tracker=ForumsTracker(self.request.user))
+
+    def threads_actions(self):
+        pass
+
+    def make_form(self):
+        self.form = None
+        list_choices = self.threads_actions();
+        if (not self.request.user.is_authenticated()
+            or not list_choices):
+            return
+
+        form_fields = {}
+        form_fields['list_action'] = forms.ChoiceField(choices=list_choices)
+        list_choices = []
+        for item in self.threads:
+            if item.forum_id == self.forum.pk:
+                list_choices.append((item.pk, None))
+        if not list_choices:
+            return
+
+        form_fields['list_items'] = forms.MultipleChoiceField(choices=list_choices, widget=forms.CheckboxSelectMultiple)
+        self.form = type('ThreadsViewForm', (Form,), form_fields)
+
+    def handle_form(self):
+        if self.request.method == 'POST':
+            self.form = self.form(self.request.POST, request=self.request)
+            if self.form.is_valid():
+                checked_items = []
+                posts = []
+                for thread in self.threads:
+                    if str(thread.pk) in self.form.cleaned_data['list_items'] and thread.forum_id == self.forum.pk:
+                        posts.append(thread.start_post_id)
+                        if thread.start_post_id != thread.last_post_id:
+                            posts.append(thread.last_post_id)
+                        checked_items.append(thread.pk)
+                if checked_items:
+                    if posts:
+                        for post in Post.objects.filter(id__in=posts).prefetch_related('user'):
+                            for thread in self.threads:
+                                if thread.start_post_id == post.pk:
+                                    thread.start_post = post
+                                if thread.last_post_id == post.pk:
+                                    thread.last_post = post
+                                if thread.start_post_id == post.pk or thread.last_post_id == post.pk:
+                                    break
+                    form_action = getattr(self, 'action_' + self.form.cleaned_data['list_action'])
+                    try:
+                        response = form_action(checked_items)
+                        if response:
+                            return response
+                        return redirect(self.request.path)
+                    except forms.ValidationError as e:
+                        self.message = Message(e.messages[0], 'error')
+                else:
+                    self.message = Message(_("You have to select at least one thread."), 'error')
+            else:
+                if 'list_action' in self.form.errors:
+                    self.message = Message(_("Action requested is incorrect."), 'error')
+                else:
+                    self.message = Message(form.non_field_errors()[0], 'error')
+        else:
+            self.form = self.form(request=self.request)
+
+    def __call__(self, request, **kwargs):
+        self.request = request
+        self.kwargs = kwargs
+        self.pagination = {}
+        self.parents = []
+        self.threads = []
+        self.message = request.messages.get_message('threads')
+        try:
+            self._fetch_forum()
+            self._check_permissions()
+            self.fetch_threads()
+            self.form = None
+            self.make_form()
+            if self.form:
+                response = self.handle_form()
+                if response:
+                    return response
+        except (Forum.DoesNotExist, Thread.DoesNotExist):
+            return error404(request)
+        except ACLError403 as e:
+            return error403(request, unicode(e))
+        except ACLError404 as e:
+            return error404(request, unicode(e))
+
+        # Merge proxy into forum
+        self.forum.closed = self.proxy.closed
+
+        return request.theme.render_to_response('%ss/%s.html' % (self.type_prefix, self.template),
+                                                self.template_vars({
+                                                 'type_prefix': self.type_prefix,
+                                                 'message': self.message,
+                                                 'forum': self.forum,
+                                                 'parents': self.parents,
+                                                 'count': self.count,
+                                                 'list_form': FormFields(self.form).fields if self.form else None,
+                                                 'threads': self.threads,
+                                                 'pagination': self.pagination,
+                                                 }),
+                                                context_instance=RequestContext(request));

+ 33 - 0
misago/apps/threadtype/mixins.py

@@ -0,0 +1,33 @@
+from django import forms
+from django.utils.translation import ungettext
+from misago.utils.strings import slugify
+
+class ValidateThreadNameMixin(object):
+    def clean_thread_name(self):
+        data = self.cleaned_data['thread_name']
+        slug = slugify(data)
+        if len(slug) < self.request.settings['thread_name_min']:
+            raise forms.ValidationError(ungettext(
+                                                  "Thread name must contain at least one alpha-numeric character.",
+                                                  "Thread name must contain at least %(count)d alpha-numeric characters.",
+                                                  self.request.settings['thread_name_min']
+                                                  ) % {'count': self.request.settings['thread_name_min']})
+        if len(data) > self.request.settings['thread_name_max']:
+            raise forms.ValidationError(ungettext(
+                                                  "Thread name cannot be longer than %(count)d character.",
+                                                  "Thread name cannot be longer than %(count)d characters.",
+                                                  self.request.settings['thread_name_max']
+                                                  ) % {'count': self.request.settings['thread_name_max']})
+        return data
+
+
+class ValidatePostLengthMixin(object):
+    def clean_post(self):
+        data = self.cleaned_data['post']
+        if len(data) < self.request.settings['post_length_min']:
+            raise forms.ValidationError(ungettext(
+                                                  "Post content cannot be empty.",
+                                                  "Post content cannot be shorter than %(count)d characters.",
+                                                  self.request.settings['post_length_min']
+                                                  ) % {'count': self.request.settings['post_length_min']})
+        return data

+ 4 - 0
misago/apps/threadtype/posting/__init__.py

@@ -0,0 +1,4 @@
+from misago.apps.threadtype.posting.newthread import NewThreadBaseView
+from misago.apps.threadtype.posting.editthread import EditThreadBaseView
+from misago.apps.threadtype.posting.newreply import NewReplyBaseView
+from misago.apps.threadtype.posting.editreply import EditReplyBaseView

+ 135 - 0
misago/apps/threadtype/posting/base.py

@@ -0,0 +1,135 @@
+from django.template import RequestContext
+from django.utils import timezone
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.apps.errors import error403, error404
+from misago.forms import FormLayout
+from misago.markdown import post_markdown
+from misago.messages import Message
+from misago.models import Forum, Thread, Post, WatchedThread
+from misago.utils.translation import ugettext_lazy
+from misago.apps.threadtype.base import ViewBase
+from misago.apps.threadtype.thread.forms import QuickReplyForm
+
+class PostingBaseView(ViewBase):
+    allow_quick_reply = False
+
+    def form_initial_data(self):
+        return {}
+
+    def _set_context(self):
+        self.set_context()
+        if self.forum.level:
+            self.parents = Forum.objects.forum_parents(self.forum.pk)
+
+    def record_edit(self, form, old_name, old_post):
+        self.post.change_set.create(
+                                    forum=self.forum,
+                                    thread=self.thread,
+                                    post=self.post,
+                                    user=self.request.user,
+                                    user_name=self.request.user.username,
+                                    user_slug=self.request.user.username_slug,
+                                    date=self.post.edit_date,
+                                    ip=self.request.session.get_ip(self.request),
+                                    agent=self.request.META.get('HTTP_USER_AGENT'),
+                                    reason=form.cleaned_data['edit_reason'],
+                                    size=len(self.post.post),
+                                    change=len(self.post.post) - len(old_post),
+                                    thread_name_old=old_name if 'thread_name' in form.cleaned_data and form.cleaned_data['thread_name'] != old_name else None,
+                                    thread_name_new=self.thread.name if 'thread_name' in form.cleaned_data and form.cleaned_data['thread_name'] != old_name else None,
+                                    post_content=old_post,
+                                    )
+
+    def after_form(self, form):
+        pass
+
+    def notify_users(self):
+        try:
+            if self.quote and self.quote.user_id:
+                del self.md.mentions[self.quote.user.username_slug]
+        except KeyError:
+            pass
+        if self.md.mentions:
+            self.post.notify_mentioned(self.request, self.type_prefix, self.md.mentions)
+            self.post.save(force_update=True)
+
+    def watch_thread(self):
+        if self.request.user.subscribe_start:
+            try:
+                WatchedThread.objects.get(user=self.request.user, thread=self.thread)
+            except WatchedThread.DoesNotExist:
+                WatchedThread.objects.create(
+                                           user=self.request.user,
+                                           forum=self.forum,
+                                           thread=self.thread,
+                                           last_read=timezone.now(),
+                                           email=(self.request.user.subscribe_start == 2),
+                                           )
+
+    def __call__(self, request, **kwargs):
+        self.request = request
+        self.kwargs = kwargs
+        self.forum = None
+        self.thread = None
+        self.quote = None
+        self.post = None
+        self.parents = []
+        self.message = request.messages.get_message('threads')
+
+        post_preview = ''
+        form = None
+
+        try:
+            self._set_context()
+            self.check_forum_type()
+            self._check_permissions()
+            if request.method == 'POST':
+                # Create correct form instance
+                if self.allow_quick_reply and 'quick_reply' in request.POST:
+                    form = QuickReplyForm(request.POST, request=request)
+                if not form or 'preview' in request.POST or not form.is_valid():
+                    # Override "quick reply" form with full one
+                    try:
+                        form = self.form_type(request.POST, request.FILE, request=request, forum=self.forum, thread=self.thread)
+                    except AttributeError:
+                        form = self.form_type(request.POST, request=request, forum=self.forum, thread=self.thread)
+                
+                # Handle specific submit
+                if 'preview' in request.POST:
+                    form.empty_errors()
+                    if form['post'].value():
+                        md, post_preview = post_markdown(request, form['post'].value())
+                    else:
+                        md, post_preview = None, None
+                else:
+                    if form.is_valid():
+                        self.post_form(form)
+                        self.watch_thread()
+                        self.after_form(form)
+                        self.notify_users()
+                        return self.response()
+                    else:
+                        self.message = Message(form.non_field_errors()[0], 'error')
+            else:
+                form = self.form_type(request=request, forum=self.forum, thread=self.thread, initial=self.form_initial_data())
+        except (Forum.DoesNotExist, Thread.DoesNotExist, Post.DoesNotExist):
+            return error404(request)
+        except ACLError403 as e:
+            return error403(request, unicode(e))
+        except ACLError404 as e:
+            return error404(request, unicode(e))
+
+        return request.theme.render_to_response(('%ss/posting.html' % self.type_prefix),
+                                                self.template_vars({
+                                                 'type_prefix': self.type_prefix,
+                                                 'action': self.action,
+                                                 'message': self.message,
+                                                 'forum': self.forum,
+                                                 'thread': self.thread,
+                                                 'quote': self.quote,
+                                                 'post': self.post,
+                                                 'parents': self.parents,
+                                                 'preview': post_preview,
+                                                 'form': FormLayout(form),
+                                                 }),
+                                                context_instance=RequestContext(request));

+ 54 - 0
misago/apps/threadtype/posting/editreply.py

@@ -0,0 +1,54 @@
+from django.utils import timezone
+from misago.apps.threadtype.posting.base import PostingBaseView
+from misago.apps.threadtype.posting.forms import EditReplyForm
+from misago.markdown import post_markdown
+
+class EditReplyBaseView(PostingBaseView):
+    action = 'edit_reply'
+    form_type = EditReplyForm
+
+    def set_context(self):
+        self.set_thread_context()
+        self.post = self.thread.post_set.get(id=self.kwargs.get('post'))
+        self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
+        self.request.acl.threads.allow_reply_edit(self.request.user, self.proxy, self.thread, self.post)
+
+    def form_initial_data(self):
+        return {
+                'weight': self.thread.weight,
+                'post': self.post.post,
+                }
+
+    def post_form(self, form):
+        now = timezone.now()
+        old_post = self.post.post
+
+        changed_thread = False
+        changed_post = old_post != form.cleaned_data['post']
+
+        if 'close_thread' in form.cleaned_data and form.cleaned_data['close_thread']:
+            self.thread.closed = not self.thread.closed
+            changed_thread = True
+            if self.thread.closed:
+                self.thread.last_post.set_checkpoint(self.request, 'closed')
+            else:
+                self.thread.last_post.set_checkpoint(self.request, 'opened')
+
+        if ('thread_weight' in form.cleaned_data and
+                form.cleaned_data['thread_weight'] != self.thread.weight):
+            self.thread.weight = form.cleaned_data['thread_weight']
+            changed_thread = True
+
+        if changed_thread:
+            self.thread.save(force_update=True)
+
+        if changed_post:
+            self.post.post = form.cleaned_data['post']
+            self.md, self.post.post_preparsed = post_markdown(self.request, form.cleaned_data['post'])
+            self.post.edits += 1
+            self.post.edit_date = now
+            self.post.edit_user = self.request.user
+            self.post.edit_user_name = self.request.user.username
+            self.post.edit_user_slug = self.request.user.username_slug
+            self.post.save(force_update=True)
+            self.record_edit(form, self.thread.name, old_post)

+ 61 - 0
misago/apps/threadtype/posting/editthread.py

@@ -0,0 +1,61 @@
+from django.utils import timezone
+from misago.apps.threadtype.posting.base import PostingBaseView
+from misago.apps.threadtype.posting.forms import EditThreadForm
+from misago.markdown import post_markdown
+from misago.utils.strings import slugify
+
+class EditThreadBaseView(PostingBaseView):
+    action = 'edit_thread'
+    form_type = EditThreadForm
+
+    def set_context(self):
+        self.set_thread_context()
+        self.post = self.thread.start_post
+        self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
+        self.request.acl.threads.allow_thread_edit(self.request.user, self.proxy, self.thread, self.post)
+        
+    def form_initial_data(self):
+        return {
+                'thread_name': self.thread.name,
+                'weight': self.thread.weight,
+                'post': self.post.post,
+                }
+
+    def post_form(self, form):
+        now = timezone.now()
+        old_name = self.thread.name
+        old_post = self.post.post
+
+        changed_thread = old_name != form.cleaned_data['thread_name']
+        changed_post = old_post != form.cleaned_data['post']
+
+        if 'close_thread' in form.cleaned_data and form.cleaned_data['close_thread']:
+            self.thread.closed = not self.thread.closed
+            changed_thread = True
+            if self.thread.closed:
+                self.thread.last_post.set_checkpoint(self.request, 'closed')
+            else:
+                self.thread.last_post.set_checkpoint(self.request, 'opened')
+
+        if ('thread_weight' in form.cleaned_data and
+                form.cleaned_data['thread_weight'] != self.thread.weight):
+            self.thread.weight = form.cleaned_data['thread_weight']
+            changed_thread = True
+
+        if changed_thread:
+            self.thread.name = form.cleaned_data['thread_name']
+            self.thread.slug = slugify(form.cleaned_data['thread_name'])
+            self.thread.save(force_update=True)
+
+        if changed_post:
+            self.post.post = form.cleaned_data['post']
+            self.md, self.post.post_preparsed = post_markdown(self.request, form.cleaned_data['post'])
+            self.post.edits += 1
+            self.post.edit_date = now
+            self.post.edit_user = self.request.user
+            self.post.edit_user_name = self.request.user.username
+            self.post.edit_user_slug = self.request.user.username_slug
+            self.post.save(force_update=True)
+
+        if changed_thread or changed_post:
+            self.record_edit(form, old_name, old_post)

+ 97 - 0
misago/apps/threadtype/posting/forms.py

@@ -0,0 +1,97 @@
+from django import forms
+from django.conf import settings
+from django.utils.translation import ugettext_lazy as _
+from misago.apps.threadtype.mixins import ValidateThreadNameMixin, ValidatePostLengthMixin
+from misago.forms import Form
+from misago.validators import validate_sluggable
+
+class PostingForm(Form, ValidatePostLengthMixin):
+    include_thread_weight = True
+    include_close_thread = True
+    post = forms.CharField(widget=forms.Textarea)
+
+    def __init__(self, data=None, file=None, request=None, forum=None, thread=None, *args, **kwargs):
+        self.forum = forum
+        self.thread = thread
+        super(PostingForm, self).__init__(data, file, request=request, *args, **kwargs)
+
+    def finalize_form(self):
+        self.layout = [
+                       [
+                        None,
+                        [
+                         ('post', {'label': _("Post Content")}),
+                         ]
+                        ]
+                       ]
+
+        # Can we change threads states?
+        if self.include_thread_weight and (self.request.acl.threads.can_pin_threads(self.forum) and
+            (not self.thread or self.request.acl.threads.can_pin_threads(self.forum) >= self.thread.weight)):
+            thread_weight = []
+            if self.request.acl.threads.can_pin_threads(self.forum) == 2:
+                thread_weight.append((2, _("Announcement")))
+            thread_weight.append((1, _("Sticky")))
+            thread_weight.append((0, _("Standard")))
+            if thread_weight:
+                self.layout[0][1].append(('thread_weight', {'label': _("Thread Importance")}))
+                try:
+                    current_weight = self.thread.weight
+                except AttributeError:
+                    current_weight = 0
+                self.fields['thread_weight'] = forms.TypedChoiceField(widget=forms.RadioSelect,
+                                                                      choices=thread_weight,
+                                                                      required=False,
+                                                                      coerce=int,
+                                                                      initial=current_weight)
+
+        # Can we lock threads?
+        if self.include_close_thread and self.request.acl.threads.can_close(self.forum):
+            self.fields['close_thread'] = forms.BooleanField(required=False)
+            if self.thread and self.thread.closed:
+                self.layout[0][1].append(('close_thread', {'inline': _("Open Thread")}))
+            else:
+                self.layout[0][1].append(('close_thread', {'inline': _("Close Thread")}))
+
+        # Give inheritor chance to set custom fields
+        try:
+            self.type_fields()
+        except AttributeError:
+            pass
+
+    def clean_thread_weight(self):
+        data = self.cleaned_data['thread_weight']
+        if not data:
+            try:
+                return self.thread.weight
+            except AttributeError:
+                pass
+            return 0
+        return data
+
+
+class NewThreadForm(PostingForm, ValidateThreadNameMixin):
+    def finalize_form(self):
+        super(NewThreadForm, self).finalize_form()
+        self.layout[0][1].append(('thread_name', {'label': _("Thread Name")}))
+        self.fields['thread_name'] = forms.CharField(max_length=self.request.settings['thread_name_max'],
+                                                     validators=[validate_sluggable(_("Thread name must contain at least one alpha-numeric character."),
+                                                                                    _("Thread name is too long. Try shorter name."))])
+
+
+class EditThreadForm(NewThreadForm, ValidateThreadNameMixin):
+    def finalize_form(self):
+        super(EditThreadForm, self).finalize_form()
+        self.fields['edit_reason'] = forms.CharField(max_length=255, required=False, help_text=_("Optional reason for editing this thread."))
+        self.layout[0][1].append(('edit_reason', {'label': _("Edit Reason")}))
+
+
+class NewReplyForm(PostingForm):
+    pass
+
+
+class EditReplyForm(PostingForm):
+    def finalize_form(self):
+        super(EditReplyForm, self).finalize_form()
+        self.fields['edit_reason'] = forms.CharField(max_length=255, required=False, help_text=_("Optional reason for editing this reply."))
+        self.layout[0][1].append(('edit_reason', {'label': _("Edit Reason")}))

+ 138 - 0
misago/apps/threadtype/posting/newreply.py

@@ -0,0 +1,138 @@
+from datetime import timedelta
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from misago.markdown import post_markdown
+from misago.models import Post
+from misago.utils.datesformats import date
+from misago.utils.translation import ugettext_lazy
+from misago.apps.threadtype.posting.base import PostingBaseView
+from misago.apps.threadtype.posting.forms import NewReplyForm
+
+class NewReplyBaseView(PostingBaseView):
+    action = 'new_reply'
+    allow_quick_reply = True
+    form_type = NewReplyForm
+
+    def set_context(self):
+        self.set_thread_context()
+        self.request.acl.threads.allow_reply(self.proxy, self.thread)
+        if self.kwargs.get('quote'):
+            self.quote = Post.objects.get(id=self.kwargs.get('quote'))
+            self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.quote)
+
+    def form_initial_data(self):
+        if self.quote:
+            quote_post = []
+            if self.quote.user:
+                quote_post.append('@%s' % self.quote.user.username)
+            else:
+                quote_post.append('@%s' % self.quote.user_name)
+            for line in self.quote.post.splitlines():
+                quote_post.append('> %s' % line)
+            quote_post.append('\r\n')
+            return {'post': '\r\n'.join(quote_post)}
+        return {}
+
+    def post_form(self, form):
+        now = timezone.now()
+        moderation = (not self.request.acl.threads.acl[self.forum.pk]['can_approve']
+                      and self.request.acl.threads.acl[self.forum.pk]['can_start_threads'] == 1)
+
+        self.thread.previous_last = self.thread.last
+        self.md, post_preparsed = post_markdown(self.request, form.cleaned_data['post'])
+
+        # Count merge diff and see if we are merging
+        merge_diff = (now - self.thread.last)
+        merge_diff = (merge_diff.days * 86400) + merge_diff.seconds
+        if (self.request.settings.post_merge_time
+                and merge_diff < (self.request.settings.post_merge_time * 60)
+                and self.thread.last_poster_id == self.request.user.id
+                and self.thread.last_post.moderated == moderation):
+            merged = True
+            self.post = self.thread.last_post
+            self.post.date = now
+            self.post.post = '%s\n\n- - -\n**%s**\n%s' % (self.post.post, _("Added on %(date)s:") % {'date': date(now, 'SHORT_DATETIME_FORMAT')}, form.cleaned_data['post'])
+            self.md, self.post.post_preparsed = post_markdown(self.request, self.post.post)
+            self.post.save(force_update=True)
+        else:
+            # Create new post
+            merged = False
+            self.post = Post.objects.create(
+                                            forum=self.forum,
+                                            thread=self.thread,
+                                            user=self.request.user,
+                                            user_name=self.request.user.username,
+                                            ip=self.request.session.get_ip(self.request),
+                                            agent=self.request.META.get('HTTP_USER_AGENT'),
+                                            post=form.cleaned_data['post'],
+                                            post_preparsed=post_preparsed,
+                                            date=now,
+                                            merge=self.thread.merges,
+                                            moderated=moderation,
+                                        )
+
+        # Update thread data and score?
+        if not moderation:
+            self.thread.new_last_post(self.post)
+
+        if not merged:
+            if not moderation:
+                self.thread.replies += 1
+            else:
+                self.thread.replies_moderated += 1
+
+            # Increase thread score
+            if self.thread.last_poster_id != self.request.user.pk:
+                self.thread.score += self.request.settings['thread_ranking_reply_score']
+
+        # Set thread status
+        if 'close_thread' in form.cleaned_data:
+            self.thread.closed = form.cleaned_data['close_thread']
+        if 'thread_weight' in form.cleaned_data:
+            self.thread.weight = form.cleaned_data['thread_weight']
+
+        # Save updated thread
+        self.thread.save(force_update=True)
+
+        # Update forum and monitor
+        if not moderation and not merged:
+            self.request.monitor['posts'] = int(self.request.monitor['posts']) + 1
+            self.forum.posts += 1
+            self.forum.new_last_thread(self.thread)
+            self.forum.save(force_update=True)
+        
+        # Reward user for posting new reply?
+        if not moderation and not merged and (not self.request.user.last_post
+                or self.request.user.last_post < timezone.now() - timedelta(seconds=self.request.settings['score_reward_new_post_cooldown'])):
+            self.request.user.score += self.request.settings['score_reward_new_post']
+
+        # Update user
+        if not moderation and not merged:
+            self.request.user.threads += 1
+            self.request.user.posts += 1
+        self.request.user.last_post = now
+        self.request.user.save(force_update=True)
+
+        # Set "closed" checkpoint, either due to thread limit or posters wish
+        if (self.request.settings.thread_length > 0
+                and not merged and not moderation and not self.thread.closed
+                and self.thread.replies >= self.request.settings.thread_length):
+            self.thread.closed = True
+            self.post.set_checkpoint(self.request, 'limit')
+        elif 'close_thread' in form.cleaned_data and form.cleaned_data['close_thread']:
+            if self.thread.closed:
+                self.thread.last_post.set_checkpoint(self.request, 'closed')
+            else:
+                self.thread.last_post.set_checkpoint(self.request, 'opened')
+
+        # Notify user we quoted?
+        if (self.quote and self.quote.user_id and not merged
+                and self.quote.user_id != self.request.user.pk
+                and not self.quote.user.is_ignoring(self.request.user)):
+            alert = self.quote.user.alert(ugettext_lazy("%(username)s has replied to your post in thread %(thread)s").message)
+            alert.profile('username', self.request.user)
+            alert.post('thread', self.type_prefix, self.thread, self.post)
+            alert.save_all()
+
+        # E-mail users about new response
+        self.thread.email_watchers(self.request, self.type_prefix, self.post)

+ 82 - 0
misago/apps/threadtype/posting/newthread.py

@@ -0,0 +1,82 @@
+from datetime import timedelta
+from django.utils import timezone
+from misago.apps.threadtype.posting.base import PostingBaseView
+from misago.apps.threadtype.posting.forms import NewThreadForm
+from misago.markdown import post_markdown
+from misago.models import Forum, Thread, Post
+from misago.utils.strings import slugify
+
+class NewThreadBaseView(PostingBaseView):
+    action = 'new_thread'
+    form_type = NewThreadForm
+
+    def set_context(self):
+        self.set_forum_context()
+        self.request.acl.forums.allow_forum_view(self.forum)
+        self.proxy = Forum.objects.parents_aware_forum(self.forum)
+        self.request.acl.threads.allow_new_threads(self.proxy)
+
+    def post_form(self, form):
+        now = timezone.now()
+        moderation = (not self.request.acl.threads.acl[self.forum.pk]['can_approve']
+                      and self.request.acl.threads.acl[self.forum.pk]['can_start_threads'] == 1)
+
+        # Create empty thread
+        self.thread = Thread.objects.create(
+                                            forum=self.forum,
+                                            name=form.cleaned_data['thread_name'],
+                                            slug=slugify(form.cleaned_data['thread_name']),
+                                            start=now,
+                                            last=now,
+                                            moderated=moderation,
+                                            score=self.request.settings['thread_ranking_initial_score'],
+                                            )
+
+        # Create our post
+        self.md, post_preparsed = post_markdown(self.request, form.cleaned_data['post'])
+        self.post = Post.objects.create(
+                                        forum=self.forum,
+                                        thread=self.thread,
+                                        user=self.request.user,
+                                        user_name=self.request.user.username,
+                                        ip=self.request.session.get_ip(self.request),
+                                        agent=self.request.META.get('HTTP_USER_AGENT'),
+                                        post=form.cleaned_data['post'],
+                                        post_preparsed=post_preparsed,
+                                        date=now,
+                                        moderated=moderation,
+                                        )
+
+        # Update thread stats to contain this post
+        self.thread.new_start_post(self.post)
+        self.thread.new_last_post(self.post)
+
+        # Set thread status
+        if 'close_thread' in form.cleaned_data:
+            self.thread.closed = form.cleaned_data['close_thread']
+        if 'thread_weight' in form.cleaned_data:
+            self.thread.weight = form.cleaned_data['thread_weight']
+
+        # Finally save complete thread
+        self.thread.save(force_update=True)
+
+        # Update forum monitor
+        if not moderation:
+            self.request.monitor['threads'] = int(self.request.monitor['threads']) + 1
+            self.request.monitor['posts'] = int(self.request.monitor['posts']) + 1
+            self.forum.threads += 1
+            self.forum.posts += 1
+            self.forum.new_last_thread(self.thread)
+            self.forum.save(force_update=True)
+
+        # Reward user for posting new thread?
+        if not moderation and (not self.request.user.last_post
+                or self.request.user.last_post < timezone.now() - timedelta(seconds=self.request.settings['score_reward_new_post_cooldown'])):
+            self.request.user.score += self.request.settings['score_reward_new_thread']
+
+        # Update user
+        if not moderation:
+            self.request.user.threads += 1
+            self.request.user.posts += 1
+        self.request.user.last_post = now
+        self.request.user.save(force_update=True)

+ 3 - 0
misago/apps/threadtype/thread/__init__.py

@@ -0,0 +1,3 @@
+from misago.apps.threadtype.thread.views import ThreadBaseView
+from misago.apps.threadtype.thread.moderation.thread import ThreadModeration
+from misago.apps.threadtype.thread.moderation.posts import PostsModeration

+ 6 - 0
misago/apps/threadtype/thread/forms.py

@@ -0,0 +1,6 @@
+from django import forms
+from misago.forms import Form
+from misago.apps.threadtype.mixins import ValidatePostLengthMixin
+
+class QuickReplyForm(Form, ValidatePostLengthMixin):
+    post = forms.CharField(widget=forms.Textarea)

+ 0 - 0
misago/profiles/details/__init__.py → misago/apps/threadtype/thread/moderation/__init__.py


+ 69 - 0
misago/apps/threadtype/thread/moderation/forms.py

@@ -0,0 +1,69 @@
+from django import forms
+from django.http import Http404
+from django.utils.translation import ugettext_lazy as _
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.apps.threadtype.mixins import ValidateThreadNameMixin
+from misago.forms import Form
+from misago.validators import validate_sluggable
+
+class SplitThreadForm(Form, ValidateThreadNameMixin):
+    def finalize_form(self):
+        self.layout = [
+                       [
+                        None,
+                        [
+                         ('thread_name', {'label': _("New Thread Name")}),
+                         ('thread_forum', {'label': _("New Thread Forum")}),
+                         ],
+                        ],
+                       ]
+
+        self.fields['thread_name'] = forms.CharField(max_length=self.request.settings['thread_name_max'],
+                                                     validators=[validate_sluggable(_("Thread name must contain at least one alpha-numeric character."),
+                                                                                    _("Thread name is too long. Try shorter name.")
+                                                                                    )])
+        self.fields['thread_forum'] = ForumChoiceField(queryset=Forum.objects.get(special='root').get_descendants().filter(pk__in=self.request.acl.forums.acl['can_browse']))
+
+    def clean_thread_forum(self):
+        new_forum = self.cleaned_data['thread_forum']
+        # Assert its forum and its not current forum
+        if new_forum.type != 'forum':
+            raise forms.ValidationError(_("This is not a forum."))
+        return new_forum
+
+
+class MovePostsForm(Form, ValidateThreadNameMixin):
+    error_source = 'thread_url'
+
+    def __init__(self, data=None, request=None, thread=None, *args, **kwargs):
+        self.thread = thread
+        super(MovePostsForm, self).__init__(data, request=request, *args, **kwargs)
+
+    def finalize_form(self):
+        self.layout = [
+                       [
+                        None,
+                        [
+                         ('thread_url', {'label': _("New Thread Link"), 'help_text': _("To select new thread, simply copy and paste here its link.")}),
+                         ],
+                        ],
+                       ]
+
+        self.fields['thread_url'] = forms.CharField()
+
+    def clean_thread_url(self):
+        from django.core.urlresolvers import resolve
+        from django.http import Http404
+        thread_url = self.cleaned_data['thread_url']
+        try:
+            thread_url = thread_url[len(settings.BOARD_ADDRESS):]
+            match = resolve(thread_url)
+            thread = Thread.objects.get(pk=match.kwargs['thread'])
+            self.request.acl.threads.allow_thread_view(self.request.user, thread)
+            if thread.pk == self.thread.pk:
+                raise forms.ValidationError(_("New thread is same as current one."))
+            return thread
+        except (Http404, KeyError):
+            raise forms.ValidationError(_("This is not a correct thread URL."))
+        except (Thread.DoesNotExist, ACLError403, ACLError404):
+            raise forms.ValidationError(_("Thread could not be found."))

+ 213 - 0
misago/apps/threadtype/thread/moderation/posts.py

@@ -0,0 +1,213 @@
+from django.core.urlresolvers import reverse
+from django import forms
+from django.shortcuts import redirect
+from django.template import RequestContext
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from misago.forms import FormLayout
+from misago.markdown import post_markdown
+from misago.messages import Message
+from misago.utils.strings import slugify
+from misago.apps.threadtype.thread.moderation.forms import SplitThreadForm, MovePostsForm
+
+class PostsModeration(object):
+    def post_action_accept(self, ids):
+        accepted = 0
+        for post in self.posts:
+            if post.pk in ids and post.moderated:
+                accepted += 1
+        if accepted:
+            self.thread.post_set.filter(id__in=ids).update(moderated=False)
+            self.thread.sync()
+            self.thread.save(force_update=True)
+            self.request.messages.set_flash(Message(_('Selected posts have been accepted and made visible to other members.')), 'success', 'threads')
+
+    def post_action_merge(self, ids):
+        users = []
+        posts = []
+        for post in self.posts:
+            if post.pk in ids:
+                posts.append(post)
+                if not post.user_id in users:
+                    users.append(post.user_id)
+                if len(users) > 1:
+                    raise forms.ValidationError(_("You cannot merge replies made by different members!"))
+        if len(posts) < 2:
+            raise forms.ValidationError(_("You have to select two or more posts you want to merge."))
+        new_post = posts[0]
+        for post in posts[1:]:
+            post.merge_with(new_post)
+            post.delete()
+        md, new_post.post_preparsed = post_markdown(self.request, new_post.post)
+        new_post.save(force_update=True)
+        self.thread.sync()
+        self.thread.save(force_update=True)
+        self.forum.sync()
+        self.forum.save(force_update=True)
+        self.request.messages.set_flash(Message(_('Selected posts have been merged into one message.')), 'success', 'threads')
+
+    def post_action_split(self, ids):
+        for id in ids:
+            if id == self.thread.start_post_id:
+                raise forms.ValidationError(_("You cannot split first post from thread."))
+        message = None
+        if self.request.POST.get('do') == 'split':
+            form = SplitThreadForm(self.request.POST, request=self.request)
+            if form.is_valid():
+                new_thread = Thread()
+                new_thread.forum = form.cleaned_data['thread_forum']
+                new_thread.name = form.cleaned_data['thread_name']
+                new_thread.slug = slugify(form.cleaned_data['thread_name'])
+                new_thread.start = timezone.now()
+                new_thread.last = timezone.now()
+                new_thread.start_poster_name = 'n'
+                new_thread.start_poster_slug = 'n'
+                new_thread.last_poster_name = 'n'
+                new_thread.last_poster_slug = 'n'
+                new_thread.save(force_insert=True)
+                prev_merge = -1
+                merge = -1
+                for post in self.posts:
+                    if post.pk in ids:
+                        if prev_merge != post.merge:
+                            prev_merge = post.merge
+                            merge += 1
+                        post.merge = merge
+                        post.move_to(new_thread)
+                        post.save(force_update=True)
+                new_thread.sync()
+                new_thread.save(force_update=True)
+                self.thread.sync()
+                self.thread.save(force_update=True)
+                self.forum.sync()
+                self.forum.save(force_update=True)
+                if new_thread.forum != self.forum:
+                    new_thread.forum.sync()
+                    new_thread.forum.save(force_update=True)
+                self.request.messages.set_flash(Message(_("Selected posts have been split to new thread.")), 'success', 'threads')
+                return redirect(reverse(self.type_prefix, kwargs={'thread': new_thread.pk, 'slug': new_thread.slug}))
+            message = Message(form.non_field_errors()[0], 'error')
+        else:
+            form = SplitThreadForm(request=self.request, initial={
+                                                                  'thread_name': _('[Split] %s') % self.thread.name,
+                                                                  'thread_forum': self.forum,
+                                                                  })
+        return self.request.theme.render_to_response('%ss/split.html' % self.type_prefix,
+                                                     {
+                                                      'type_prefix': self.type_prefix,
+                                                      'message': message,
+                                                      'forum': self.forum,
+                                                      'parents': self.parents,
+                                                      'thread': self.thread,
+                                                      'posts': ids,
+                                                      'form': FormLayout(form),
+                                                      },
+                                                     context_instance=RequestContext(self.request));
+
+    def post_action_move(self, ids):
+        message = None
+        if self.request.POST.get('do') == 'move':
+            form = MovePostsForm(self.request.POST, request=self.request, thread=self.thread)
+            if form.is_valid():
+                thread = form.cleaned_data['thread_url']
+                prev_merge = -1
+                merge = -1
+                for post in self.posts:
+                    if post.pk in ids:
+                        if prev_merge != post.merge:
+                            prev_merge = post.merge
+                            merge += 1
+                        post.merge = merge + thread.merges
+                        post.move_to(thread)
+                        post.save(force_update=True)
+                if self.thread.post_set.count() == 0:
+                    self.thread.delete()
+                else:
+                    self.thread.sync()
+                    self.thread.save(force_update=True)
+                thread.sync()
+                thread.save(force_update=True)
+                thread.forum.sync()
+                thread.forum.save(force_update=True)
+                if self.forum.pk != thread.forum.pk:
+                    self.forum.sync()
+                    self.forum.save(force_update=True)
+                self.request.messages.set_flash(Message(_("Selected posts have been moved to new thread.")), 'success', 'threads')
+                return redirect(reverse(self.type_prefix, kwargs={'thread': thread.pk, 'slug': thread.slug}))
+            message = Message(form.non_field_errors()[0], 'error')
+        else:
+            form = MovePostsForm(request=self.request)
+        return self.request.theme.render_to_response('%ss/move_posts.html' % self.type_prefix,
+                                                     {
+                                                      'type_prefix': self.type_prefix,
+                                                      'message': message,
+                                                      'forum': self.forum,
+                                                      'parents': self.parents,
+                                                      'thread': self.thread,
+                                                      'posts': ids,
+                                                      'form': FormLayout(form),
+                                                      },
+                                                     context_instance=RequestContext(self.request));
+
+    def post_action_undelete(self, ids):
+        undeleted = []
+        for post in self.posts:
+            if post.pk in ids and post.deleted:
+                undeleted.append(post.pk)
+        if undeleted:
+            self.thread.post_set.filter(id__in=undeleted).update(deleted=False)
+            self.thread.sync()
+            self.thread.save(force_update=True)
+            self.forum.sync()
+            self.forum.save(force_update=True)
+            self.request.messages.set_flash(Message(_('Selected posts have been restored.')), 'success', 'threads')
+
+    def post_action_protect(self, ids):
+        protected = 0
+        for post in self.posts:
+            if post.pk in ids and not post.protected:
+                protected += 1
+        if protected:
+            self.thread.post_set.filter(id__in=ids).update(protected=True)
+            self.request.messages.set_flash(Message(_('Selected posts have been protected from edition.')), 'success', 'threads')
+
+    def post_action_unprotect(self, ids):
+        unprotected = 0
+        for post in self.posts:
+            if post.pk in ids and post.protected:
+                unprotected += 1
+        if unprotected:
+            self.thread.post_set.filter(id__in=ids).update(protected=False)
+            self.request.messages.set_flash(Message(_('Protection from editions has been removed from selected posts.')), 'success', 'threads')
+
+    def post_action_soft(self, ids):
+        deleted = []
+        for post in self.posts:
+            if post.pk in ids and not post.deleted:
+                if post.pk == self.thread.start_post_id:
+                    raise forms.ValidationError(_("You cannot delete first post of thread using this action. If you want to delete thread, use thread moderation instead."))
+                deleted.append(post.pk)
+        if deleted:
+            self.thread.post_set.filter(id__in=deleted).update(deleted=True)
+            self.thread.sync()
+            self.thread.save(force_update=True)
+            self.forum.sync()
+            self.forum.save(force_update=True)
+            self.request.messages.set_flash(Message(_('Selected posts have been deleted.')), 'success', 'threads')
+
+    def post_action_hard(self, ids):
+        deleted = []
+        for post in self.posts:
+            if post.pk in ids:
+                if post.pk == self.thread.start_post_id:
+                    raise forms.ValidationError(_("You cannot delete first post of thread using this action. If you want to delete thread, use thread moderation instead."))
+                deleted.append(post.pk)
+        if deleted:
+            for post in self.posts:
+                if post.pk in deleted:
+                    post.delete()
+            self.thread.sync()
+            self.thread.save(force_update=True)
+            self.forum.sync()
+            self.forum.save(force_update=True)
+            self.request.messages.set_flash(Message(_('Selected posts have been deleted.')), 'success', 'threads')

+ 130 - 0
misago/apps/threadtype/thread/moderation/thread.py

@@ -0,0 +1,130 @@
+from django.template import RequestContext
+from django.utils.translation import ugettext as _
+from misago.forms import Form, FormLayout
+from misago.messages import Message
+from misago.apps.threadtype.list.forms import MoveThreadsForm
+
+class ThreadModeration(object):
+    def thread_action_accept(self):
+        # Sync thread and post
+        self.thread.moderated = False
+        self.thread.replies_moderated -= 1
+        self.thread.save(force_update=True)
+        self.thread.start_post.moderated = False
+        self.thread.start_post.save(force_update=True)
+        self.thread.last_post.set_checkpoint(self.request, 'accepted')
+        # Sync user
+        if self.thread.last_post.user:
+            self.thread.start_post.user.threads += 1
+            self.thread.start_post.user.posts += 1
+            self.thread.start_post.user.save(force_update=True)
+        # Sync forum
+        self.forum.sync()
+        self.forum.save(force_update=True)
+        # Update monitor
+        self.request.monitor['threads'] = int(self.request.monitor['threads']) + 1
+        self.request.monitor['posts'] = int(self.request.monitor['posts']) + self.thread.replies + 1
+        self.request.messages.set_flash(Message(_('Thread has been marked as reviewed and made visible to other members.')), 'success', 'threads')
+
+    def thread_action_annouce(self):
+        self.thread.weight = 2
+        self.thread.save(force_update=True)
+        self.request.messages.set_flash(Message(_('Thread has been turned into announcement.')), 'success', 'threads')
+
+    def thread_action_sticky(self):
+        self.thread.weight = 1
+        self.thread.save(force_update=True)
+        self.request.messages.set_flash(Message(_('Thread has been turned into sticky.')), 'success', 'threads')
+
+    def thread_action_normal(self):
+        self.thread.weight = 0
+        self.thread.save(force_update=True)
+        self.request.messages.set_flash(Message(_('Thread weight has been changed to normal.')), 'success', 'threads')
+
+    def thread_action_move(self):
+        message = None
+        if self.request.POST.get('do') == 'move':
+            form = MoveThreadsForm(self.request.POST, request=self.request, forum=self.forum)
+            if form.is_valid():
+                new_forum = form.cleaned_data['new_forum']
+                self.thread.move_to(new_forum)
+                self.thread.save(force_update=True)
+                self.forum.sync()
+                self.forum.save(force_update=True)
+                new_forum.sync()
+                new_forum.save(force_update=True)
+                self.request.messages.set_flash(Message(_('Thread has been moved to "%(forum)s".') % {'forum': new_forum.name}), 'success', 'threads')
+                return None
+            message = Message(form.non_field_errors()[0], 'error')
+        else:
+            form = MoveThreadsForm(request=self.request, forum=self.forum)
+        return self.request.theme.render_to_response('%ss/move_thread.html' % self.type_prefix,
+                                                     {
+                                                      'type_prefix': self.type_prefix,
+                                                      'message': message,
+                                                      'forum': self.forum,
+                                                      'parents': self.parents,
+                                                      'thread': self.thread,
+                                                      'form': FormLayout(form),
+                                                      },
+                                                     context_instance=RequestContext(self.request));
+
+    def thread_action_open(self):
+        self.thread.closed = False
+        self.thread.save(force_update=True)
+        self.thread.last_post.set_checkpoint(self.request, 'opened')
+        self.request.messages.set_flash(Message(_('Thread has been opened.')), 'success', 'threads')
+
+    def thread_action_close(self):
+        self.thread.closed = True
+        self.thread.save(force_update=True)
+        self.thread.last_post.set_checkpoint(self.request, 'closed')
+        self.request.messages.set_flash(Message(_('Thread has been closed.')), 'success', 'threads')
+
+    def thread_action_undelete(self):
+        # Update thread
+        self.thread.deleted = False
+        self.thread.replies_deleted -= 1
+        self.thread.save(force_update=True)
+        # Update first post in thread
+        self.thread.start_post.deleted = False
+        self.thread.start_post.save(force_update=True)
+        # Set checkpoint
+        self.thread.last_post.set_checkpoint(self.request, 'undeleted')
+        # Update forum
+        self.forum.sync()
+        self.forum.save(force_update=True)
+        # Update monitor
+        self.request.monitor['threads'] = int(self.request.monitor['threads']) + 1
+        self.request.monitor['posts'] = int(self.request.monitor['posts']) + self.thread.replies + 1
+        self.request.messages.set_flash(Message(_('Thread has been undeleted.')), 'success', 'threads')
+
+    def thread_action_soft(self):
+        # Update thread
+        self.thread.deleted = True
+        self.thread.replies_deleted += 1
+        self.thread.save(force_update=True)
+        # Update first post in thread
+        self.thread.start_post.deleted = True
+        self.thread.start_post.save(force_update=True)
+        # Set checkpoint
+        self.thread.last_post.set_checkpoint(self.request, 'deleted')
+        # Update forum
+        self.forum.sync()
+        self.forum.save(force_update=True)
+        # Update monitor
+        self.request.monitor['threads'] = int(self.request.monitor['threads']) - 1
+        self.request.monitor['posts'] = int(self.request.monitor['posts']) - self.thread.replies - 1
+        self.request.messages.set_flash(Message(_('Thread has been deleted.')), 'success', 'threads')
+
+    def thread_action_hard(self):
+        # Delete thread
+        self.thread.delete()
+        # Update forum
+        self.forum.sync()
+        self.forum.save(force_update=True)
+        # Update monitor
+        self.request.monitor['threads'] = int(self.request.monitor['threads']) - 1
+        self.request.monitor['posts'] = int(self.request.monitor['posts']) - self.thread.replies - 1
+        self.request.messages.set_flash(Message(_('Thread "%(thread)s" has been deleted.') % {'thread': self.thread.name}), 'success', 'threads')
+        return self.threads_list_redirect()

+ 210 - 0
misago/apps/threadtype/thread/views.py

@@ -0,0 +1,210 @@
+from django import forms
+from django.core.urlresolvers import reverse
+from django.forms import ValidationError
+from django.shortcuts import redirect
+from django.template import RequestContext
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.apps.errors import error403, error404
+from misago.forms import Form, FormLayout, FormFields
+from misago.messages import Message
+from misago.models import Forum, Thread, Post, Karma, WatchedThread
+from misago.readstrackers import ThreadsTracker
+from misago.utils.pagination import make_pagination
+from misago.apps.threadtype.base import ViewBase
+from misago.apps.threadtype.thread.forms import QuickReplyForm
+
+class ThreadBaseView(ViewBase):
+    def fetch_thread(self):
+        self.thread = Thread.objects.get(pk=self.kwargs.get('thread'))
+        self.forum = self.thread.forum
+        self.proxy = Forum.objects.parents_aware_forum(self.forum)
+        self.request.acl.forums.allow_forum_view(self.forum)
+        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
+
+        if self.forum.level:
+            self.parents = Forum.objects.forum_parents(self.forum.pk, True)
+
+        self.tracker = ThreadsTracker(self.request, self.forum)
+        if self.request.user.is_authenticated():
+            try:
+                self.watcher = WatchedThread.objects.get(user=self.request.user, thread=self.thread)
+            except WatchedThread.DoesNotExist:
+                pass
+
+    def fetch_posts(self):
+        self.count = self.request.acl.threads.filter_posts(self.request, self.thread, Post.objects.filter(thread=self.thread)).count()
+        self.posts = self.request.acl.threads.filter_posts(self.request, self.thread, Post.objects.filter(thread=self.thread)).prefetch_related('checkpoint_set', 'user', 'user__rank')
+        
+        if self.thread.merges > 0:
+            self.posts = self.posts.order_by('merge', 'pk')
+        else:
+            self.posts = self.posts.order_by('pk')
+
+        self.pagination = make_pagination(self.kwargs.get('page', 0), self.count, self.request.settings.posts_per_page)
+        if self.request.settings.posts_per_page < self.count:
+            self.posts = self.posts[self.pagination['start']:self.pagination['stop']]
+
+        self.read_date = self.tracker.read_date(self.thread)
+
+        ignored_users = []
+        if self.request.user.is_authenticated():
+            ignored_users = self.request.user.ignored_users()
+
+        posts_dict = {}
+        for post in self.posts:
+            posts_dict[post.pk] = post
+            post.message = self.request.messages.get_message('threads_%s' % post.pk)
+            post.is_read = post.date <= self.read_date or (post.pk != self.thread.start_post_id and post.moderated)
+            post.karma_vote = None
+            post.ignored = self.thread.start_post_id != post.pk and not self.thread.pk in self.request.session.get('unignore_threads', []) and post.user_id in ignored_users
+            if post.ignored:
+                self.ignored = True
+
+        last_post = self.posts[len(self.posts) - 1]
+
+        if not self.tracker.is_read(self.thread):
+            self.tracker_update(last_post)
+
+        if self.watcher and last_post.date > self.watcher.last_read:
+            self.watcher.last_read = timezone.now()
+            self.watcher.save(force_update=True)
+
+        if self.request.user.is_authenticated():
+            for karma in Karma.objects.filter(post_id__in=posts_dict.keys()).filter(user=self.request.user):
+                posts_dict[karma.post_id].karma_vote = karma
+
+    def tracker_update(self, last_post):
+        self.tracker.set_read(self.thread, last_post)
+        self.tracker.sync()
+
+    def thread_actions(self):
+        pass
+
+    def make_thread_form(self):
+        self.thread_form = None
+        list_choices = self.thread_actions();
+        if (not self.request.user.is_authenticated()
+            or not list_choices):
+            return
+        form_fields = {'thread_action': forms.ChoiceField(choices=list_choices)}
+        self.thread_form = type('ThreadViewForm', (Form,), form_fields)
+
+    def handle_thread_form(self):
+        if self.request.method == 'POST' and self.request.POST.get('origin') == 'thread_form':
+            self.thread_form = self.thread_form(self.request.POST, request=self.request)
+            if self.thread_form.is_valid():
+                form_action = getattr(self, 'thread_action_' + self.thread_form.cleaned_data['thread_action'])
+                try:
+                    response = form_action()
+                    if response:
+                        return response
+                    return redirect(self.request.path)
+                except forms.ValidationError as e:
+                    self.message = Message(e.messages[0], 'error')
+            else:
+                if 'thread_action' in self.thread_form.errors:
+                    self.message = Message(_("Action requested is incorrect."), 'error')
+                else:
+                    self.message = Message(form.non_field_errors()[0], 'error')
+        else:
+            self.thread_form = self.thread_form(request=self.request)
+
+    def posts_actions(self):
+        pass
+
+    def make_posts_form(self):
+        self.posts_form = None
+        list_choices = self.posts_actions();
+        if (not self.request.user.is_authenticated()
+            or not list_choices):
+            return
+
+        form_fields = {}
+        form_fields['list_action'] = forms.ChoiceField(choices=list_choices)
+        list_choices = []
+        for item in self.posts:
+            list_choices.append((item.pk, None))
+        if not list_choices:
+            return
+        form_fields['list_items'] = forms.MultipleChoiceField(choices=list_choices, widget=forms.CheckboxSelectMultiple)
+        self.posts_form = type('PostsViewForm', (Form,), form_fields)
+    
+    def handle_posts_form(self):
+        if self.request.method == 'POST' and self.request.POST.get('origin') == 'posts_form':
+            self.posts_form = self.posts_form(self.request.POST, request=self.request)
+            if self.posts_form.is_valid():
+                checked_items = []
+                for post in self.posts:
+                    if str(post.pk) in self.posts_form.cleaned_data['list_items']:
+                        checked_items.append(post.pk)
+                if checked_items:
+                    form_action = getattr(self, 'post_action_' + self.posts_form.cleaned_data['list_action'])
+                    try:
+                        response = form_action(checked_items)
+                        if response:
+                            return response
+                        return redirect(self.request.path)
+                    except forms.ValidationError as e:
+                        self.message = Message(e.messages[0], 'error')
+                else:
+                    self.message = Message(_("You have to select at least one post."), 'error')
+            else:
+                if 'list_action' in self.posts_form.errors:
+                    self.message = Message(_("Action requested is incorrect."), 'error')
+                else:
+                    self.message = Message(posts_form.non_field_errors()[0], 'error')
+        else:
+            self.posts_form = self.posts_form(request=self.request)
+
+    def __call__(self, request, **kwargs):
+        self.request = request
+        self.kwargs = kwargs
+        self.parents = []
+        self.ignored = False
+        self.watcher = False
+        self.message = request.messages.get_message('threads')
+        try:
+            self.fetch_thread()
+            self.check_forum_type()
+            self._check_permissions()
+            self.fetch_posts()
+            self.make_thread_form()
+            if self.thread_form:
+                response = self.handle_thread_form()
+                if response:
+                    return response
+            self.make_posts_form()
+            if self.posts_form:
+                response = self.handle_posts_form()
+                if response:
+                    return response
+        except (Forum.DoesNotExist, Thread.DoesNotExist):
+            return error404(request)
+        except ACLError403 as e:
+            return error403(request, unicode(e))
+        except ACLError404 as e:
+            return error404(request, unicode(e))
+
+        # Merge proxy into forum
+        self.forum.closed = self.proxy.closed
+
+        return request.theme.render_to_response('%ss/thread.html' % self.type_prefix,
+                                                self.template_vars({
+                                                 'type_prefix': self.type_prefix,
+                                                 'message': self.message,
+                                                 'forum': self.forum,
+                                                 'parents': self.parents,
+                                                 'thread': self.thread,
+                                                 'is_read': self.tracker.is_read(self.thread),
+                                                 'count': self.count,
+                                                 'posts': self.posts,
+                                                 'ignored_posts': self.ignored,
+                                                 'watcher': self.watcher,
+                                                 'pagination': self.pagination,
+                                                 'quick_reply': FormFields(QuickReplyForm(request=request)).fields,
+                                                 'thread_form': FormFields(self.thread_form).fields if self.thread_form else None,
+                                                 'posts_form': FormFields(self.posts_form).fields if self.posts_form else None,
+                                                 }),
+                                                context_instance=RequestContext(request));

+ 2 - 2
misago/tos/views.py → misago/apps/tos.py

@@ -1,7 +1,7 @@
 from django.template import RequestContext
-from misago.views import error404
+from misago.apps.errors import error404
 
-def forum_tos(request):
+def tos(request):
     if request.settings.tos_url or not request.settings.tos_content:
         return error404(request)
     return request.theme.render_to_response('forum_tos.html',

+ 0 - 0
misago/profiles/followers/__init__.py → misago/apps/usercp/__init__.py


+ 0 - 0
misago/profiles/follows/__init__.py → misago/apps/usercp/avatar/__init__.py


+ 0 - 0
misago/usercp/avatar/forms.py → misago/apps/usercp/avatar/forms.py


+ 3 - 3
misago/usercp/avatar/urls.py → misago/apps/usercp/avatar/urls.py

@@ -3,15 +3,15 @@ from django.conf.urls import patterns, url
 def register_usercp_urls(first=False):
     urlpatterns = []
     if first:
-        urlpatterns += patterns('misago.usercp.avatar.views',
+        urlpatterns += patterns('misago.apps.usercp.avatar.views',
             url(r'^$', 'avatar', name="usercp"),
             url(r'^$', 'avatar', name="usercp_avatar"),
         )
     else:
-        urlpatterns += patterns('misago.usercp.avatar.views',
+        urlpatterns += patterns('misago.apps.usercp.avatar.views',
             url(r'^avatar/$', 'avatar', name="usercp_avatar"),
         )
-    urlpatterns += patterns('misago.usercp.avatar.views',
+    urlpatterns += patterns('misago.apps.usercp.avatar.views',
         url(r'^avatar/gallery/$', 'gallery', name="usercp_avatar_gallery"),
         url(r'^avatar/upload/$', 'upload', name="usercp_avatar_upload"),
         url(r'^avatar/upload/crop/$', 'crop', name="usercp_avatar_upload_crop", kwargs={'upload': True}),

+ 0 - 0
misago/usercp/avatar/usercp.py → misago/apps/usercp/avatar/usercp.py


+ 12 - 11
misago/usercp/avatar/views.py → misago/apps/usercp/avatar/views.py

@@ -4,15 +4,16 @@ from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
+from django.utils.encoding import smart_str
 from django.utils.translation import ugettext as _
-from misago.authn.decorators import block_guest
+from misago.apps.errors import error404
+from misago.decorators import block_guest
 from misago.forms import FormLayout
 from misago.messages import Message
-from misago.usercp.template import RequestContext
-from misago.usercp.avatar.forms import UploadAvatarForm
-from misago.views import error404
-from misago.utils import get_random_string
+from misago.utils.strings import random_string
 from misago.utils.avatars import resizeimage
+from misago.apps.usercp.template import RequestContext
+from misago.apps.usercp.avatar.forms import UploadAvatarForm
 
 def avatar_view(f):
     def decorator(*args, **kwargs):
@@ -111,8 +112,8 @@ def upload(request):
         if form.is_valid():
             request.user.delete_avatar_temp()
             image = form.cleaned_data['avatar_upload']
-            image_name, image_extension = path(image.name.lower()).splitext()
-            image_name = '%s_tmp_%s%s' % (request.user.pk, get_random_string(8), image_extension)
+            image_name, image_extension = path(smart_str(image.name.lower())).splitext()
+            image_name = '%s_tmp_%s%s' % (request.user.pk, random_string(8), image_extension)
             image_path = settings.MEDIA_ROOT + 'avatars/' + image_name
             request.user.avatar_temp = image_name
 
@@ -132,7 +133,7 @@ def upload(request):
                 image_path = settings.MEDIA_ROOT + 'avatars/'
                 source = Image.open(image_path + request.user.avatar_temp)
                 image_name, image_extension = path(request.user.avatar_temp).splitext()
-                image_name = '%s_%s%s' % (request.user.pk, get_random_string(8), image_extension)
+                image_name = '%s_%s%s' % (request.user.pk, random_string(8), image_extension)
                 resizeimage(source, settings.AVATAR_SIZES[0], image_path + image_name, info=source.info, format=source.format)
                 for size in settings.AVATAR_SIZES[1:]:
                     resizeimage(source, size, image_path + str(size) + '_' + image_name, info=source.info, format=source.format)
@@ -140,7 +141,7 @@ def upload(request):
                 request.user.delete_avatar_image()
                 request.user.delete_avatar_original()
                 request.user.avatar_type = 'upload'
-                request.user.avatar_original = '%s_org_%s%s' % (request.user.pk, get_random_string(8), image_extension)
+                request.user.avatar_original = '%s_org_%s%s' % (request.user.pk, random_string(8), image_extension)
                 source.save(image_path + request.user.avatar_original)
                 request.user.delete_avatar_temp()
                 request.user.avatar_image = image_name
@@ -196,7 +197,7 @@ def crop(request, upload=False):
                     image_name, image_extension = path(request.user.avatar_temp).splitext()
                 else:
                     image_name, image_extension = path(request.user.avatar_original).splitext()
-                image_name = '%s_%s%s' % (request.user.pk, get_random_string(8), image_extension)
+                image_name = '%s_%s%s' % (request.user.pk, random_string(8), image_extension)
                 resizeimage(crop, settings.AVATAR_SIZES[0], image_path + image_name, info=source.info, format=source.format)
                 for size in settings.AVATAR_SIZES[1:]:
                     resizeimage(crop, size, image_path + str(size) + '_' + image_name, info=source.info, format=source.format)
@@ -205,7 +206,7 @@ def crop(request, upload=False):
                 if upload:
                     request.user.delete_avatar_original()
                     request.user.avatar_type = 'upload'
-                    request.user.avatar_original = '%s_org_%s%s' % (request.user.pk, get_random_string(8), image_extension)
+                    request.user.avatar_original = '%s_org_%s%s' % (request.user.pk, random_string(8), image_extension)
                     source.save(image_path + request.user.avatar_original)
                 request.user.delete_avatar_temp()
                 request.user.avatar_image = image_name

+ 0 - 0
misago/profiles/posts/__init__.py → misago/apps/usercp/credentials/__init__.py


+ 2 - 2
misago/usercp/credentials/forms.py → misago/apps/usercp/credentials/forms.py

@@ -3,8 +3,8 @@ from django import forms
 from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext_lazy as _
 from misago.forms import Form
-from misago.users.models import User
-from misago.users.validators import validate_password, validate_email
+from misago.models import User
+from misago.validators import validate_password, validate_email
 
 class CredentialsChangeForm(Form):
     new_email = forms.EmailField(max_length=255, required=False)

+ 5 - 3
misago/usercp/credentials/urls.py → misago/apps/usercp/credentials/urls.py

@@ -3,15 +3,17 @@ from django.conf.urls import patterns, url
 def register_usercp_urls(first=False):
     urlpatterns = []
     if first:
-        urlpatterns += patterns('misago.usercp.credentials.views',
+        urlpatterns += patterns('misago.apps.usercp.credentials.views',
             url(r'^$', 'credentials', name="usercp"),
             url(r'^$', 'credentials', name="usercp_credentials"),
         )
     else:
-        urlpatterns += patterns('misago.usercp.credentials.views',
+        urlpatterns += patterns('misago.apps.usercp.credentials.views',
             url(r'^credentials/$', 'credentials', name="usercp_credentials"),
         )
-    urlpatterns += patterns('misago.usercp.credentials.views',
+
+    urlpatterns += patterns('misago.apps.usercp.credentials.views',
         url(r'^credentials/activate/(?P<token>[a-zA-Z0-9]+)/$', 'activate', name="usercp_credentials_activate"),
     )
+    
     return urlpatterns

+ 0 - 0
misago/usercp/credentials/usercp.py → misago/apps/usercp/credentials/usercp.py


+ 6 - 6
misago/usercp/credentials/views.py → misago/apps/usercp/credentials/views.py

@@ -2,13 +2,13 @@ from django.core.exceptions import ValidationError
 from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
 from django.utils.translation import ugettext as _
-from misago.authn.decorators import block_guest
+from misago.apps.errors import error404
+from misago.decorators import block_guest
 from misago.forms import FormLayout
 from misago.messages import Message
-from misago.usercp.template import RequestContext
-from misago.usercp.credentials.forms import CredentialsChangeForm
-from misago.views import error404
-from misago.utils import get_random_string
+from misago.utils.strings import random_string
+from misago.apps.usercp.template import RequestContext
+from misago.apps.usercp.credentials.forms import CredentialsChangeForm
 
 @block_guest
 def credentials(request):
@@ -16,7 +16,7 @@ def credentials(request):
     if request.method == 'POST':
         form = CredentialsChangeForm(request.POST, request=request)
         if form.is_valid():
-            token = get_random_string(12)
+            token = random_string(12)
             request.user.email_user(
                                     request,
                                     'users/new_credentials',

+ 0 - 0
misago/profiles/threads/__init__.py → misago/apps/usercp/options/__init__.py


+ 53 - 0
misago/apps/usercp/options/forms.py

@@ -0,0 +1,53 @@
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+from misago.forms import Form
+from misago.utils.timezones import tzlist
+
+class UserForumOptionsForm(Form):
+    newsletters = forms.BooleanField(required=False)
+    timezone = forms.ChoiceField(choices=tzlist())
+    hide_activity = forms.TypedChoiceField(choices=(
+                                                    (0, _("Show my presence to everyone")),
+                                                    (1, _("Show my presence to people I follow")),
+                                                    (2, _("Show my presence to nobody")),
+                                                    ), coerce=int)
+    subscribe_start = forms.TypedChoiceField(choices=(
+                                                      (0, _("Don't watch")),
+                                                      (1, _("Put on watched threads list")),
+                                                      (2, _("Put on watched threads list and e-mail me when somebody replies")),
+                                                      ), coerce=int)
+    subscribe_reply = forms.TypedChoiceField(choices=(
+                                                      (0, _("Don't watch")),
+                                                      (1, _("Put on watched threads list")),
+                                                      (2, _("Put on watched threads list and e-mail me when somebody replies")),
+                                                      ), coerce=int)
+    allow_pds = forms.TypedChoiceField(choices=(
+                                                (0, _("From everyone")),
+                                                (1, _("From everyone but not members I ignore")),
+                                                (2, _("From members I follow")),
+                                                (2, _("From nobody")),
+                                                ), coerce=int)
+
+    layout = (
+              (
+               _("Privacy"),
+               (
+                ('hide_activity', {'label': _("Your Visibility"), 'help_text': _("If you want to, you can limit other members ability to track your presence on forums.")}),
+                ('allow_pds', {'label': _("Allow Private Threads Invitations"), 'help_text': _("If you wish, you can restrict who can invite you to private threads. Keep in mind some groups or members may be allowed to override this preference.")}),
+                )
+               ),
+              (
+               _("Forum Options"),
+               (
+                ('timezone', {'label': _("Your Current Timezone"), 'help_text': _("If dates and hours displayed by forums are inaccurate, you can fix it by adjusting timezone setting.")}),
+                ('newsletters', {'label': _("Newsletters"), 'help_text': _("On occasion board administrator may want to send e-mail message to multiple members."), 'inline': _("Yes, I want to subscribe forum newsletter")}),
+                )
+               ),
+              (
+               _("Watching Threads"),
+               (
+                ('subscribe_start', {'label': _("Threads I start")}),
+                ('subscribe_reply', {'label': _("Threads I reply to")}),
+                )
+               ),
+              )

+ 2 - 2
misago/usercp/options/urls.py → misago/apps/usercp/options/urls.py

@@ -2,10 +2,10 @@ from django.conf.urls import patterns, url
 
 def register_usercp_urls(first=False):
     if first:
-        return patterns('misago.usercp.options.views',
+        return patterns('misago.apps.usercp.options.views',
             url(r'^$', 'options', name="usercp"),
             url(r'^$', 'options', name="usercp_options"),
         )
-    return patterns('misago.usercp.options.views',
+    return patterns('misago.apps.usercp.options.views',
         url(r'^options/$', 'options', name="usercp_options"),
     )

+ 0 - 0
misago/usercp/options/usercp.py → misago/apps/usercp/options/usercp.py


+ 6 - 5
misago/usercp/options/views.py → misago/apps/usercp/options/views.py

@@ -1,12 +1,11 @@
 from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
 from django.utils.translation import ugettext as _
+from misago.decorators import block_guest
 from misago.forms import FormLayout
 from misago.messages import Message
-from misago.authn.decorators import block_guest
-from misago.usercp.options.forms import UserForumOptionsForm
-from misago.usercp.template import RequestContext
-
+from misago.apps.usercp.options.forms import UserForumOptionsForm
+from misago.apps.usercp.template import RequestContext
 
 @block_guest
 def options(request):
@@ -14,8 +13,9 @@ def options(request):
     if request.method == 'POST':
         form = UserForumOptionsForm(request.POST, request=request)
         if form.is_valid():
-            request.user.receive_newsletters = form.cleaned_data['newsletters']
             request.user.hide_activity = form.cleaned_data['hide_activity']
+            request.user.allow_pds = form.cleaned_data['allow_pds']
+            request.user.receive_newsletters = form.cleaned_data['newsletters']
             request.user.timezone = form.cleaned_data['timezone']
             request.user.subscribe_start = form.cleaned_data['subscribe_start']
             request.user.subscribe_reply = form.cleaned_data['subscribe_reply']
@@ -27,6 +27,7 @@ def options(request):
         form = UserForumOptionsForm(request=request, initial={
                                                              'newsletters': request.user.receive_newsletters,
                                                              'hide_activity': request.user.hide_activity,
+                                                             'allow_pds': request.user.allow_pds,
                                                              'timezone': request.user.timezone,
                                                              'subscribe_start': request.user.subscribe_start,
                                                              'subscribe_reply': request.user.subscribe_reply,

+ 0 - 0
misago/prune/__init__.py → misago/apps/usercp/signature/__init__.py


+ 0 - 0
misago/usercp/signature/forms.py → misago/apps/usercp/signature/forms.py


+ 3 - 2
misago/usercp/signature/urls.py → misago/apps/usercp/signature/urls.py

@@ -2,10 +2,11 @@ from django.conf.urls import patterns, url
 
 def register_usercp_urls(first=False):
     if first:
-        return patterns('misago.usercp.signature.views',
+        return patterns('misago.apps.usercp.signature.views',
             url(r'^$', 'signature', name="usercp"),
             url(r'^$', 'signature', name="usercp_signature"),
         )
-    return patterns('misago.usercp.signature.views',
+    
+    return patterns('misago.apps.usercp.signature.views',
         url(r'^signature/$', 'signature', name="usercp_signature"),
     )

+ 0 - 0
misago/usercp/signature/usercp.py → misago/apps/usercp/signature/usercp.py


+ 4 - 4
misago/usercp/signature/views.py → misago/apps/usercp/signature/views.py

@@ -1,13 +1,13 @@
 from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
 from django.utils.translation import ugettext as _
-from misago.authn.decorators import block_guest
+from misago.apps.errors import error403, error404
+from misago.decorators import block_guest
 from misago.forms import FormLayout
 from misago.markdown import signature_markdown
 from misago.messages import Message
-from misago.usercp.template import RequestContext
-from misago.usercp.signature.forms import SignatureForm
-from misago.views import error403, error404
+from misago.apps.usercp.template import RequestContext
+from misago.apps.usercp.signature.forms import SignatureForm
 
 @block_guest
 def signature(request):

+ 0 - 0
misago/usercp/template.py → misago/apps/usercp/template.py


+ 1 - 1
misago/usercp/urls.py → misago/apps/usercp/urls.py

@@ -14,7 +14,7 @@ for extension in settings.USERCP_EXTENSIONS:
     except AttributeError:
         pass
 
-urlpatterns += patterns('misago.usercp.views',
+urlpatterns += patterns('misago.apps.usercp.views',
     url(r'^follow/(?P<user>\d+)/$', 'follow', name="follow_user"),
     url(r'^unfollow/(?P<user>\d+)/$', 'unfollow', name="unfollow_user"),
     url(r'^ignore/(?P<user>\d+)/$', 'ignore', name="ignore_user"),

+ 0 - 0
misago/prune/migrations/__init__.py → misago/apps/usercp/username/__init__.py


+ 1 - 1
misago/usercp/username/forms.py → misago/apps/usercp/username/forms.py

@@ -1,8 +1,8 @@
 from django import forms
 from django.core.exceptions import ValidationError
-from misago.users.validators import validate_username
 from django.utils.translation import ugettext_lazy as _
 from misago.forms import Form
+from misago.validators import validate_username
 
 class UsernameChangeForm(Form):
     username = forms.CharField(max_length=255)

+ 2 - 2
misago/usercp/username/urls.py → misago/apps/usercp/username/urls.py

@@ -2,10 +2,10 @@ from django.conf.urls import patterns, url
 
 def register_usercp_urls(first=False):
     if first:
-        return patterns('misago.usercp.username.views',
+        return patterns('misago.apps.usercp.username.views',
             url(r'^$', 'username', name="usercp"),
             url(r'^$', 'username', name="usercp_username"),
         )
-    return patterns('misago.usercp.username.views',
+    return patterns('misago.apps.usercp.username.views',
         url(r'^username/$', 'username', name="usercp_username"),
     )

+ 0 - 0
misago/usercp/username/usercp.py → misago/apps/usercp/username/usercp.py


+ 6 - 8
misago/usercp/username/views.py → misago/apps/usercp/username/views.py

@@ -4,16 +4,14 @@ from django.db.models import F
 from django.shortcuts import redirect
 from django.utils import timezone
 from django.utils.translation import ugettext as _
-from misago.alerts.models import Alert
-from misago.authn.decorators import block_guest
+from misago.apps.errors import error404
+from misago.decorators import block_guest
 from misago.forms import FormLayout
 from misago.messages import Message
-from misago.users.models import User
-from misago.usercp.template import RequestContext
-from misago.usercp.models import UsernameChange
-from misago.usercp.username.forms import UsernameChangeForm
-from misago.views import error404
-from misago.utils import ugettext_lazy
+from misago.models import Alert, User, UsernameChange
+from misago.utils.translation import ugettext_lazy
+from misago.apps.usercp.template import RequestContext
+from misago.apps.usercp.username.forms import UsernameChangeForm
 
 @block_guest
 def username(request):

+ 6 - 6
misago/usercp/views.py → misago/apps/usercp/views.py

@@ -1,13 +1,12 @@
 from django.core.urlresolvers import NoReverseMatch
 from django.shortcuts import redirect
 from django.utils.translation import ugettext as _
-from misago.authn.decorators import block_guest
-from misago.csrf.decorators import check_csrf
+from misago.apps.errors import error404
+from misago.apps.profiles.decorators import user_view
+from misago.decorators import block_guest, check_csrf
 from misago.messages import Message
-from misago.users.models import User
-from misago.profiles.decorators import user_view
-from misago.views import error404
-from misago.utils import ugettext_lazy
+from misago.models import User
+from misago.utils.translation import ugettext_lazy
 
 def fallback(request):
     try:
@@ -15,6 +14,7 @@ def fallback(request):
     except NoReverseMatch:
         return redirect('index')
 
+
 @block_guest
 @check_csrf
 @user_view

+ 0 - 0
misago/ranks/__init__.py → misago/apps/watchedthreads/__init__.py


+ 8 - 0
misago/apps/watchedthreads/urls.py

@@ -0,0 +1,8 @@
+from django.conf.urls import patterns, url
+
+urlpatterns = patterns('misago.apps.watchedthreads.views',
+    url(r'^$', 'watched_threads', name="watched_threads"),
+    url(r'^(?P<page>\d+)/$', 'watched_threads', name="watched_threads"),
+    url(r'^new/$', 'watched_threads', name="watched_threads_new", kwargs={'new': True}),
+    url(r'^new/(?P<page>\d+)/$', 'watched_threads', name="watched_threads_new", kwargs={'new': True}),
+)

+ 4 - 4
misago/watcher/views.py → misago/apps/watchedthreads/views.py

@@ -4,16 +4,16 @@ from django.db.models import F
 from django.shortcuts import redirect
 from django.template import RequestContext
 from django.utils.translation import ugettext as _
-from misago.authn.decorators import block_guest
+from misago.decorators import block_guest
 from misago.forms import Form, FormLayout, FormFields
 from misago.messages import Message
-from misago.watcher.models import ThreadWatch
-from misago.utils import make_pagination
+from misago.models import Forum, WatchedThread
+from misago.utils.pagination import make_pagination
 
 @block_guest
 def watched_threads(request, page=0, new=False):
     # Find mode and fetch threads
-    queryset = ThreadWatch.objects.filter(user=request.user).filter(forum_id__in=request.acl.threads.get_readable_forums(request.acl)).select_related('thread').filter(thread__moderated=False).filter(thread__deleted=False)
+    queryset = WatchedThread.objects.filter(user=request.user).filter(forum_id__in=Forum.objects.readable_forums(request.acl, True)).select_related('thread').filter(thread__moderated=False).filter(thread__deleted=False)
     if new:
         queryset = queryset.filter(last_read__lt=F('thread__last'))
     count = queryset.count()

+ 6 - 8
misago/authn/methods.py → misago/auth.py

@@ -2,10 +2,7 @@ from datetime import timedelta
 from django.conf import settings
 from django.utils import timezone
 from django.utils.translation import ugettext_lazy as _
-from misago.banning.models import check_ban
-from misago.bruteforce.models import SignInAttempt
-from misago.sessions.models import Token
-from misago.users.models import User
+from misago.models import Ban, SignInAttempt, Token, User
 
 """
 Exception constants
@@ -58,7 +55,7 @@ def auth_forum(request, email, password):
     Forum auth - check bans and if we are in maintenance - maintenance access
     """
     user = get_user(email, password)
-    user_ban = check_ban(username=user.username, email=user.email)
+    user_ban = Ban.objects.check_ban(username=user.username, email=user.email)
     if user_ban:
         if user_ban.reason_user:
             raise AuthException(BANNED, _("Your account has been banned for following reason:"), ban=user_ban)
@@ -80,10 +77,11 @@ def auth_remember(request, ip):
         cookie_token = request.COOKIES[cookie_token]
         if len(cookie_token) != 42:
             raise AuthException()
+            
         try:
             token_rk = Token.objects.select_related().get(pk=cookie_token)
         except Token.DoesNotExist:
-            request.cookie_jar.delete('TOKEN')
+            request.cookiejar.delete('TOKEN')
             raise AuthException()
 
         # See if token is not expired
@@ -99,7 +97,7 @@ def auth_remember(request, ip):
         # Update token date
         token_rk.accessed = timezone.now()
         token_rk.save(force_update=True)
-        request.cookie_jar.set('TOKEN', token_rk.id, True)
+        request.cookiejar.set('TOKEN', token_rk.id, True)
     except (AttributeError, KeyError):
         raise AuthException()
     return token_rk
@@ -110,7 +108,7 @@ def auth_admin(request, email, password):
     Admin auth - check ACP permissions
     """
     user = get_user(email, password, True)
-    if not user.is_god() and not user.get_acl(request).admin.is_admin():
+    if not user.is_god() and not user.acl(request).special.is_admin():
         raise AuthException(NOT_ADMIN, _("Your account does not have admin privileges."))
     return user;
 

+ 0 - 20
misago/authn/decorators.py

@@ -1,20 +0,0 @@
-from django.utils.translation import ugettext as _
-
-def block_authenticated(f):
-    def decorator(*args, **kwargs):
-        request = args[0]
-        if not request.firewall.admin and request.user.is_authenticated():
-            from misago.views import error403
-            return error403(request, _("%(username)s, this page is not available to signed in users.") % {'username': request.user.username})
-        return f(*args, **kwargs)
-    return decorator
-
-
-def block_guest(f):
-    def decorator(*args, **kwargs):
-        request = args[0]
-        if not request.user.is_authenticated():
-            from misago.views import error403
-            return error403(request, _("Dear Guest, only signed in members are allowed to access this page. Please sign in or register and try again."))
-        return f(*args, **kwargs)
-    return decorator

+ 0 - 7
misago/banning/context_processors.py

@@ -1,7 +0,0 @@
-def banning(request):
-    try:
-        return {
-            'is_banned': request.ban.is_banned(),
-        }
-    except AttributeError:
-        return {}

+ 0 - 12
misago/banning/decorators.py

@@ -1,12 +0,0 @@
-def block_banned(f):
-    def decorator(*args, **kwargs):
-        request = args[0]
-        try:
-            if request.ban.is_banned():
-                from misago.banning.views import error_banned
-                return error_banned(request);
-            return f(*args, **kwargs)
-        except AttributeError:
-            pass
-        return f(*args, **kwargs)
-    return decorator

+ 0 - 4
misago/banning/fixtures.py

@@ -1,4 +0,0 @@
-from misago.monitor.fixtures import load_monitor_fixture
-
-def load_fixtures():
-    load_monitor_fixture({'bans_version': 0})

+ 0 - 40
misago/banning/migrations/0001_initial.py

@@ -1,40 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Ban'
-        db.create_table(u'banning_ban', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('type', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('ban', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('reason_user', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('reason_admin', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('expires', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
-        ))
-        db.send_create_signal(u'banning', ['Ban'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Ban'
-        db.delete_table(u'banning_ban')
-
-
-    models = {
-        u'banning.ban': {
-            'Meta': {'object_name': 'Ban'},
-            'ban': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'expires': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'type': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        }
-    }
-
-    complete_apps = ['banning']

+ 0 - 84
misago/banning/models.py

@@ -1,84 +0,0 @@
-import re
-from django.utils import timezone
-from django.db import models
-from django.db.models import Q
-
-BAN_NAME_EMAIL = 0
-BAN_NAME = 1
-BAN_EMAIL = 2
-BAN_IP = 3
-
-
-class Ban(models.Model):
-    type = models.PositiveIntegerField(default=BAN_NAME_EMAIL)
-    ban = models.CharField(max_length=255)
-    reason_user = models.TextField(null=True, blank=True)
-    reason_admin = models.TextField(null=True, blank=True)
-    expires = models.DateTimeField(null=True, blank=True)
-
-
-def check_ban(ip=False, username=False, email=False):
-    bans_model = Ban.objects.filter(Q(expires=None) | Q(expires__gt=timezone.now()))
-    if not (ip and username and email):
-        if ip:
-            bans_model.filter(type=BAN_IP)
-        if username:
-            bans_model.filter(type=BAN_NAME_EMAIL)
-            bans_model.filter(type=BAN_NAME)
-        if email:
-            bans_model.filter(type=BAN_NAME_EMAIL)
-            bans_model.filter(type=BAN_EMAIL)
-    for ban in bans_model.order_by('-expires').iterator():
-        if (
-            # Check user name
-            ((username and (ban.type == BAN_NAME_EMAIL or ban.type == BAN_NAME))
-            and re.search('^' + re.escape(ban.ban).replace('\*', '(.*?)') + '$', username, flags=re.IGNORECASE))
-            or # Check user email
-            ((email and (ban.type == BAN_NAME_EMAIL or ban.type == BAN_EMAIL))
-            and re.search('^' + re.escape(ban.ban).replace('\*', '(.*?)') + '$', email, flags=re.IGNORECASE))
-            or # Check IP address
-            (ip and ban.type == BAN_IP
-            and re.search('^' + re.escape(ban.ban).replace('\*', '(.*?)') + '$', ip, flags=re.IGNORECASE))):
-                return ban
-    return False
-
-
-class BanCache(object):
-    def __init__(self):
-        self.banned = False
-        self.type = None
-        self.expires = None
-        self.reason_user = None
-        self.version = 0
-
-    def check_for_updates(self, request):
-        if (self.version < request.monitor['bans_version']
-            or (self.expires != None and self.expires < timezone.now())):
-            self.version = request.monitor['bans_version']
-
-            # Check Ban
-            if request.user.is_authenticated():
-                ban = check_ban(
-                                ip=request.session.get_ip(request),
-                                username=request.user.username,
-                                email=request.user.email
-                                )
-            else:
-                ban = check_ban(ip=request.session.get_ip(request))
-
-            # Update ban cache
-            if ban:
-                self.banned = True
-                self.reason_user = ban.reason_user
-                self.expires = ban.expires
-                self.type = ban.type
-            else:
-                self.banned = False
-                self.reason_user = None
-                self.expires = None
-                self.type = None
-            return True
-        return False
-
-    def is_banned(self):
-        return self.banned

+ 0 - 7
misago/bruteforce/context_processors.py

@@ -1,7 +0,0 @@
-def is_jammed(request):
-    try:
-        return {
-            'is_jammed': request.jam.is_jammed(),
-        }
-    except AttributeError:
-        return {}

+ 0 - 13
misago/bruteforce/decorators.py

@@ -1,13 +0,0 @@
-from django.utils.translation import ugettext as _
-from misago.views import error403
-
-def block_jammed(f):
-    def decorator(*args, **kwargs):
-        request = args[0]
-        try:
-            if not request.firewall.admin and request.jam.is_jammed():
-                return error403(request, _("You have used up allowed attempts quota and we temporarily banned you from accessing this page."))
-        except AttributeError:
-            pass
-        return f(*args, **kwargs)
-    return decorator

+ 0 - 13
misago/bruteforce/middleware.py

@@ -1,13 +0,0 @@
-from misago.bruteforce.models import JamCache
-
-class JamMiddleware(object):
-    def process_request(self, request):
-        if request.user.is_crawler():
-            return None
-        try:
-            request.jam = request.session['jam']
-        except KeyError:
-            request.jam = JamCache()
-            request.session['jam'] = request.jam
-        if not request.firewall.admin:
-            request.jam.check_for_updates(request)

+ 0 - 34
misago/bruteforce/migrations/0001_initial.py

@@ -1,34 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'SignInAttempt'
-        db.create_table(u'bruteforce_signinattempt', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
-            ('date', self.gf('django.db.models.fields.DateTimeField')()),
-        ))
-        db.send_create_signal(u'bruteforce', ['SignInAttempt'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'SignInAttempt'
-        db.delete_table(u'bruteforce_signinattempt')
-
-
-    models = {
-        u'bruteforce.signinattempt': {
-            'Meta': {'object_name': 'SignInAttempt'},
-            'date': ('django.db.models.fields.DateTimeField', [], {}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'})
-        }
-    }
-
-    complete_apps = ['bruteforce']

+ 0 - 30
misago/captcha/__init__.py

@@ -1,30 +0,0 @@
-from django.forms.fields import CharField
-from django.forms.widgets import TextInput
-from django.utils.translation import ugettext_lazy as _
-from recaptcha.client.captcha import API_SSL_SERVER, API_SERVER, VERIFY_SERVER
-
-class ReCaptchaWidget(TextInput):
-    pass
-
-
-class ReCaptchaField(CharField):
-    widget = ReCaptchaWidget # Fakey widget for FormLayout
-    api_error = None # Api error
-    def __init__(self, label=_("Verification Code"), *args, **kwargs):
-        kwargs['label'], kwargs['required'] = label, False
-        super(ReCaptchaField, self).__init__(*args, **kwargs)
-
-
-class QACaptchaField(CharField):
-    pass
-
-
-def get_captcha_dict(settings, api_error = None):
-    error_param = ''
-    if api_error:
-        error_param = '&error=%s' % api_error
-    return {
-            'api_server': API_SERVER,
-            'public_key': settings['recaptcha_public'],
-            'error_param': error_param,
-            }

+ 31 - 8
misago/context_processors.py

@@ -1,10 +1,33 @@
 from django.conf import settings
-from misago import get_version
+from misago import __version__
+from misago.admin import site
+from misago.models import Forum
 
-# Register context processors
-def core(request):
-    return {
-        'request_path': request.get_full_path(),
-        'board_address': settings.BOARD_ADDRESS,
-        'version': get_version(),
-    }
+def common(request):
+    try:
+        context = {
+            'acl': request.acl,
+            'board_address': settings.BOARD_ADDRESS,
+            'messages' : request.messages.messages,
+            'monitor': request.monitor,
+            'request_path': request.get_full_path(),
+            'settings': request.settings,
+            'stopwatch': request.stopwatch.time(),
+            'user': request.user,
+            'version': __version__,
+        }
+        context.update({
+            'csrf_id': request.csrf.csrf_id,
+            'csrf_token': request.csrf.csrf_token,
+            'is_banned': request.ban.is_banned(),
+            'is_jammed': request.jam.is_jammed(),
+            'private_threads': Forum.objects.special_model('private_threads'),
+            'reports': Forum.objects.special_model('reports'),
+        })
+    except AttributeError as e:
+        pass
+    return context
+
+
+def admin(request):
+    return site.get_admin_navigation(request)

+ 0 - 0
misago/cookie_jar/cookie_jar.py → misago/cookiejar.py


+ 17 - 1
misago/crawlers/crawler.py → misago/crawlers.py

@@ -1,4 +1,20 @@
-from database import CRAWLERS_NAMES, CRAWLERS_AGENTS, CRAWLERS_HOSTS
+CRAWLERS_NAMES = {
+    'bing': 'Bingbot',
+    'google': 'Googlebot',
+    'yahoo': 'Yahoo! Slurp',
+    'yahooch': 'Yahoo! Slurp China',
+}
+
+CRAWLERS_AGENTS = {
+    'bingbot/': 'bing',
+    'Googlebot/': 'google',
+    'Yahoo! Slurp China': 'yahooch',
+    'Yahoo! Slurp': 'yahoo',
+}
+
+CRAWLERS_HOSTS = {
+}
+
 
 class Crawler(object):
     crawler = False

+ 0 - 16
misago/crawlers/database.py

@@ -1,16 +0,0 @@
-CRAWLERS_NAMES = {
-    'bing': 'Bingbot',
-    'google': 'Googlebot',
-    'yahoo': 'Yahoo! Slurp',
-    'yahooch': 'Yahoo! Slurp China',
-}
-
-CRAWLERS_AGENTS = {
-    'bingbot/': 'bing',
-    'Googlebot/': 'google',
-    'Yahoo! Slurp China': 'yahooch',
-    'Yahoo! Slurp': 'yahoo',
-}
-
-CRAWLERS_HOSTS = {
-}

+ 0 - 9
misago/crawlers/decorators.py

@@ -1,9 +0,0 @@
-from misago.views import error403
-
-def block_crawlers(f):
-    def decorator(*args, **kwargs):
-        request = args[0]
-        if request.user.is_crawler():
-            return error403(request)
-        return f(*args, **kwargs)
-    return decorator

+ 0 - 7
misago/csrf/__init__.py

@@ -1,7 +0,0 @@
-class CSRFProtection(object):
-    def __init__(self, csrf_token):
-        self.csrf_id = '_csrf_token'
-        self.csrf_token = csrf_token
-        
-    def request_secure(self, request):
-        return request.method == 'POST' and request.POST.get(self.csrf_id) == self.csrf_token

+ 0 - 8
misago/csrf/context_processors.py

@@ -1,8 +0,0 @@
-def csrf(request):
-    try:
-        return {
-            'csrf_id': request.csrf.csrf_id,
-            'csrf_token': request.csrf.csrf_token,
-        }
-    except AttributeError:
-        return {}

+ 0 - 10
misago/csrf/decorators.py

@@ -1,10 +0,0 @@
-from django.utils.translation import ugettext as _
-
-def check_csrf(f):
-    def decorator(*args, **kwargs):
-        request = args[0]
-        if not request.csrf.request_secure(request):
-            from misago.views import error403
-            return error403(request, _("Request authorization is invalid. Please try again."))
-        return f(*args, **kwargs)
-    return decorator

+ 0 - 13
misago/csrf/middleware.py

@@ -1,13 +0,0 @@
-from misago.csrf import CSRFProtection
-from misago.utils import get_random_string
-
-class CSRFMiddleware(object):
-    def process_request(self, request):
-        if request.user.is_crawler():
-            return None
-        if 'csrf_token' in request.session:
-            csrf_token = request.session['csrf_token']
-        else:
-            csrf_token = get_random_string(16);
-            request.session['csrf_token'] = csrf_token
-        request.csrf = CSRFProtection(csrf_token)

+ 5 - 2
misago/settings/settings.py → misago/dbsettings.py

@@ -1,8 +1,11 @@
 from django.db.utils import DatabaseError
 from django.core.cache import cache
-from misago.settings.models import Setting
+from misago.models import Setting
 
-class Settings(object):
+class DBSettings(object):
+    """
+    Database-stored high-level and "safe" settings controller
+    """
     def __init__(self):
         self._settings = {}
         self._models = {}

+ 74 - 0
misago/decorators.py

@@ -0,0 +1,74 @@
+from django.utils.translation import ugettext as _
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.apps.errors import error403, error404, error_banned
+
+def acl_errors(f):
+    def decorator(*args, **kwargs):
+        try:
+            return f(*args, **kwargs)
+        except ACLError403 as e:
+            return error403(args[0], e)
+        except ACLError404 as e:
+            return error404(args[0], e)
+    return decorator
+
+
+def block_authenticated(f):
+    def decorator(*args, **kwargs):
+        request = args[0]
+        if not request.firewall.admin and request.user.is_authenticated():
+            return error403(request, _("%(username)s, this page is not available to signed in users.") % {'username': request.user.username})
+        return f(*args, **kwargs)
+    return decorator
+
+
+def block_banned(f):
+    def decorator(*args, **kwargs):
+        request = args[0]
+        try:
+            if request.ban.is_banned():
+                return error_banned(request);
+            return f(*args, **kwargs)
+        except AttributeError:
+            pass
+        return f(*args, **kwargs)
+    return decorator
+
+
+def block_crawlers(f):
+    def decorator(*args, **kwargs):
+        request = args[0]
+        if request.user.is_crawler():
+            return error403(request)
+        return f(*args, **kwargs)
+    return decorator
+
+
+def block_guest(f):
+    def decorator(*args, **kwargs):
+        request = args[0]
+        if not request.user.is_authenticated():
+            return error403(request, _("Dear Guest, only signed in members are allowed to access this page. Please sign in or register and try again."))
+        return f(*args, **kwargs)
+    return decorator
+
+
+def block_jammed(f):
+    def decorator(*args, **kwargs):
+        request = args[0]
+        try:
+            if not request.firewall.admin and request.jam.is_jammed():
+                return error403(request, _("You have used up allowed attempts quota and we temporarily banned you from accessing this page."))
+        except AttributeError:
+            pass
+        return f(*args, **kwargs)
+    return decorator
+
+
+def check_csrf(f):
+    def decorator(*args, **kwargs):
+        request = args[0]
+        if not request.csrf.request_secure(request):
+            return error403(request, _("Request authorization is invalid. Please try again."))
+        return f(*args, **kwargs)
+    return decorator

+ 5 - 6
misago/firewalls/firewalls.py → misago/firewalls.py

@@ -2,15 +2,13 @@ from django.conf import settings
 from django.utils.translation import ugettext_lazy as _
 from misago.admin import ADMIN_PATH
 from misago.messages import Message
-from misago.views import error403, error404
-from misago.authn.views import signin
+from misago.apps.errors import error403, error404
+from misago.apps.signin.views import signin
 
 class FirewallForum(object):
-    """
-    Firewall Abstraction
-    """
     admin = False
     prefix = ''
+
     def behind_firewall(self, path):
         """
         Firewall test, it checks if requested path is behind firewall
@@ -24,6 +22,7 @@ class FirewallForum(object):
 class FirewallAdmin(FirewallForum):
     admin = True
     prefix = '/' + ADMIN_PATH
+
     def process_view(self, request, callback, callback_args, callback_kwargs):
         # Block all crawlers with 403
         if request.user.is_crawler():
@@ -33,7 +32,7 @@ class FirewallAdmin(FirewallForum):
             # If we are not authenticated or not admin, force us to sign in right way
             if not request.user.is_authenticated():
                 return signin(request)
-            elif not request.user.is_god() and not request.acl.admin.is_admin():
+            elif not request.user.is_god() and not request.acl.special.is_admin():
                 request.messages.set_message(Message(_("Your account does not have admin privileges")), 'error', 'security')
                 return signin(request)
             return None

+ 0 - 0
misago/ranks/management/__init__.py → misago/fixtures/__init__.py


+ 15 - 8
misago/register/fixtures.py → misago/fixtures/accountssetings.py

@@ -1,8 +1,7 @@
-from misago.settings.fixtures import load_settings_fixture, update_settings_fixture
-from misago.utils import ugettext_lazy as _
-from misago.utils import get_msgid
+from misago.utils.fixtures import load_settings_fixture, update_settings_fixture
+from misago.utils.translation import ugettext_lazy as _
 
-settings_fixtures = (
+settings_fixture = (
     # Register and Sign-In Settings
     ('accounts', {
         'name': _("Users Accounts Settings"),
@@ -89,14 +88,22 @@ settings_fixtures = (
                                              )},
                 'name':         _("Watch threads user replied in"),
             }),
+            ('profiles_per_list', {
+                'value':        24,
+                'type':         "integer",
+                'input':        "string",
+                'extra':        {'min': 1, 'max': 128},
+                'separator':    _("Users List"),
+                'name':         _("Number of Profiles Per Page"),
+            }),
         ),
     }),
 )
 
 
-def load_fixtures():
-    load_settings_fixture(settings_fixtures)
+def load():
+    load_settings_fixture(settings_fixture)
     
     
-def update_fixtures():
-    update_settings_fixture(settings_fixtures)
+def update():
+    update_settings_fixture(settings_fixture)

+ 4 - 0
misago/fixtures/aclmonitor.py

@@ -0,0 +1,4 @@
+from misago.utils.fixtures import load_monitor_fixture
+
+def load():
+    load_monitor_fixture({'acl_version': 0})

+ 7 - 8
misago/usercp/fixtures.py → misago/fixtures/avatarssettings.py

@@ -1,8 +1,7 @@
-from misago.settings.fixtures import load_settings_fixture, update_settings_fixture
-from misago.utils import ugettext_lazy as _
-from misago.utils import get_msgid
+from misago.utils.fixtures import load_settings_fixture, update_settings_fixture
+from misago.utils.translation import ugettext_lazy as _
 
-settings_fixtures = (
+settings_fixture = (
     # Avatars Settings
     ('avatars', {
          'name': _("Users Avatars Settings"),
@@ -39,9 +38,9 @@ settings_fixtures = (
 )
 
 
-def load_fixtures():
-    load_settings_fixture(settings_fixtures)
+def load():
+    load_settings_fixture(settings_fixture)
 
 
-def update_fixtures():
-    update_settings_fixture(settings_fixtures)
+def update():
+    update_settings_fixture(settings_fixture)

+ 4 - 0
misago/fixtures/bansmonitor.py

@@ -0,0 +1,4 @@
+from misago.utils.fixtures import load_monitor_fixture
+
+def load():
+    load_monitor_fixture({'bans_version': 0})

+ 72 - 0
misago/fixtures/basicsettings.py

@@ -0,0 +1,72 @@
+from misago.utils.fixtures import load_settings_fixture, update_settings_fixture
+from misago.utils.translation import ugettext_lazy as _
+
+settings_fixture = (
+   # Basic options
+   ('basic', {
+        'name': _("Basic Settings"),
+        'settings': (
+            ('board_name', {
+                'value':        "Misago",
+                'type':         "string",
+                'input':        "text",
+                'separator':    _("Board Name"),
+                'name':         _("Board Name"),
+            }),
+            ('board_header', {
+                'type':         "string",
+                'input':        "text",
+                'name':         _("Board Header"),
+                'description':  _("Some themes allow you to define text in board header. Leave empty to use Board Name instead."),
+            }),
+            ('board_header_postscript', {
+                'value':        "Work in progress",
+                'type':         "string",
+                'input':        "text",
+                'name':         _("Board Header Postscript"),
+                'description':  _("Additional text displayed in some themes board header after board name."),
+            }),
+            ('board_index_title', {
+                'type':         "string",
+                'input':        "text",
+                'separator':    _("Board Index"),
+                'name':         _("Board Index Title"),
+                'description':  _("If you want to, you can replace page title content on Board Index with custom one."),
+            }),
+            ('board_index_meta', {
+                'type':         "string",
+                'input':        "text",
+                'name':         _("Board Index Meta-Description"),
+                'description':  _("Meta-Description used to describe your board's index page."),
+            }),
+            ('board_credits', {
+                'type':         "string",
+                'input':        "textarea",
+                'separator':    _("Board Footer"),
+                'name':         _("Custom Credit"),
+                'description':  _("Custom Credit to display in board footer above software and theme copyright information. You can use HTML."),
+            }),
+            ('email_footnote', {
+                'type':         "string",
+                'input':        "textarea",
+                'separator':    _("Board E-Mails"),
+                'name':         _("Custom Footnote in HTML E-mails"),
+                'description':  _("Custom Footnote to display in HTML e-mail messages sent by board."),
+            }),
+            ('email_footnote_plain', {
+                'type':         "string",
+                'input':        "textarea",
+                'name':         _("Custom Footnote in plain text E-mails"),
+                'description':  _("Custom Footnote to display in plain text e-mail messages sent by board."),
+            }),
+        ),
+   }),
+)
+
+
+def load():
+    load_settings_fixture(settings_fixture)
+
+
+def update():
+    update_settings_fixture(settings_fixture)

+ 7 - 8
misago/bruteforce/fixtures.py → misago/fixtures/bruteforcesettings.py

@@ -1,8 +1,7 @@
-from misago.settings.fixtures import load_settings_fixture, update_settings_fixture
-from misago.utils import ugettext_lazy as _
-from misago.utils import get_msgid
+from misago.utils.fixtures import load_settings_fixture, update_settings_fixture
+from misago.utils.translation import ugettext_lazy as _
 
-settings_fixtures = (
+settings_fixture = (
     # Register and Sign-In Settings
     ('brute-force', {
         'name': _("Brute-force Countermeasures"),
@@ -38,9 +37,9 @@ settings_fixtures = (
 )
 
 
-def load_fixtures():
-    load_settings_fixture(settings_fixtures)
+def load():
+    load_settings_fixture(settings_fixture)
 
 
-def update_fixtures():
-    update_settings_fixture(settings_fixtures)
+def update():
+    update_settings_fixture(settings_fixture)

+ 8 - 10
misago/captcha/fixtures.py → misago/fixtures/captchasettings.py

@@ -1,11 +1,9 @@
-from misago.settings.fixtures import load_settings_fixture, update_settings_fixture
-from misago.utils import ugettext_lazy as _
-from misago.utils import get_msgid
+from misago.utils.fixtures import load_settings_fixture, update_settings_fixture
+from misago.utils.translation import ugettext_lazy as _
 
-
-settings_fixtures = (
+settings_fixture = (
    # Spam Countermeasures
-   ('spam', {
+   ('captcha', {
         'name': _("Spam Countermeasures"),
         'description': _("Those settings allow you to combat automatic registrations and spam messages on your forum."),
         'settings': (
@@ -61,9 +59,9 @@ settings_fixtures = (
 )
 
 
-def load_fixtures():
-    load_settings_fixture(settings_fixtures)
+def load():
+    load_settings_fixture(settings_fixture)
 
 
-def update_fixtures():
-    update_settings_fixture(settings_fixtures)
+def update():
+    update_settings_fixture(settings_fixture)

+ 8 - 13
misago/forums/fixtures.py → misago/fixtures/forums.py

@@ -1,15 +1,13 @@
 from django.utils import timezone
-from misago.monitor.fixtures import load_monitor_fixture
-from misago.forums.models import Forum
-from misago.threads.models import Thread, Post
-from misago.utils import slugify
+from misago.models import Forum, Thread, Post 
+from misago.utils.fixtures import load_monitor_fixture
+from misago.utils.strings import slugify
 
-def load_fixtures():
-    Forum(token='annoucements', name='annoucements', slug='annoucements', type='forum').insert_at(None, save=True)
-    Forum(token='private', name='private', slug='private', type='forum').insert_at(None, save=True)
-    Forum(token='reports', name='reports', slug='reports', type='forum').insert_at(None, save=True)
+def load():
+    Forum(special='private_threads', name='private', slug='private', type='forum').insert_at(None, save=True)
+    Forum(special='reports', name='reports', slug='reports', type='forum').insert_at(None, save=True)
 
-    root = Forum(token='root', name='root', slug='root')
+    root = Forum(special='root', name='root', slug='root')
     root.insert_at(None, save=True)
     cat = Forum(type='category', name='First Category', slug='first-category')
     cat.insert_at(root, save=True)
@@ -52,7 +50,4 @@ def load_fixtures():
     forum.last_poster_slug = thread.last_poster_slug
     forum.save(force_update=True)
 
-    load_monitor_fixture({
-                          'threads': 1,
-                          'posts': 1,
-                          })
+    load_monitor_fixture({'threads': 1, 'posts': 1})

+ 114 - 0
misago/fixtures/forumsroles.py

@@ -0,0 +1,114 @@
+from misago.models import ForumRole
+from misago.utils.translation import ugettext_lazy as _
+
+def load():
+    role = ForumRole()
+    role.name = _('Full Access').message
+    role.permissions = {
+                        'can_see_forum': True,
+                        'can_see_forum_contents': True,
+                        'can_read_threads': 2,
+                        'can_start_threads': 2,
+                        'can_edit_own_threads': True,
+                        'can_soft_delete_own_threads': True,
+                        'can_write_posts': 2,
+                        'can_edit_own_posts': True,
+                        'can_soft_delete_own_posts': True,
+                        'can_upvote_posts': True,
+                        'can_downvote_posts': True,
+                        'can_see_posts_scores': 2,
+                        'can_see_votes': True,
+                        'can_make_polls': True,
+                        'can_vote_in_polls': True,
+                        'can_see_poll_votes': True,
+                        'can_see_attachments': True,
+                        'can_upload_attachments': True,
+                        'can_download_attachments': True,
+                        'attachment_size': 5000,
+                        'attachment_limit': 15,
+                        'can_approve': True,
+                        'can_edit_labels': True,
+                        'can_see_changelog': True,
+                        'can_pin_threads': 2,
+                        'can_edit_threads_posts': True,
+                        'can_move_threads_posts': True,
+                        'can_close_threads': True,
+                        'can_protect_posts': True,
+                        'can_delete_threads': 2,
+                        'can_delete_posts': 2,
+                        'can_delete_polls': 2,
+                        'can_delete_attachments': True,
+                       }
+    role.save(force_insert=True)
+
+    role = ForumRole()
+    role.name = _('Standard Access and Upload').message
+    role.permissions = {
+                        'can_see_forum': True,
+                        'can_see_forum_contents': True,
+                        'can_read_threads': 2,
+                        'can_start_threads': 2,
+                        'can_edit_own_threads': True,
+                        'can_write_posts': 2,
+                        'can_edit_own_posts': True,
+                        'can_soft_delete_own_posts': True,
+                        'can_upvote_posts': True,
+                        'can_downvote_posts': True,
+                        'can_see_posts_scores': 2,
+                        'can_make_polls': True,
+                        'can_vote_in_polls': True,
+                        'can_upload_attachments': True,
+                        'can_download_attachments': True,
+                        'attachment_size': 100,
+                        'attachment_limit': 3,
+                       }
+    role.save(force_insert=True)
+
+    role = ForumRole()
+    role.name = _('Standard Access').message
+    role.permissions = {
+                        'can_see_forum': True,
+                        'can_see_forum_contents': True,
+                        'can_read_threads': 2,
+                        'can_start_threads': 2,
+                        'can_edit_own_threads': True,
+                        'can_write_posts': 2,
+                        'can_edit_own_posts': True,
+                        'can_soft_delete_own_posts': True,
+                        'can_upvote_posts': True,
+                        'can_downvote_posts': True,
+                        'can_see_posts_scores': 2,
+                        'can_make_polls': True,
+                        'can_vote_in_polls': True,
+                        'can_download_attachments': True,
+                       }
+    role.save(force_insert=True)
+
+    role = ForumRole()
+    role.name = _('Read and Download').message
+    role.permissions = {
+                        'can_see_forum': True,
+                        'can_see_forum_contents': True,
+                        'can_read_threads': 2,
+                        'can_download_attachments': True,
+                        'can_see_posts_scores': 2,
+                       }
+    role.save(force_insert=True)
+
+    role = ForumRole()
+    role.name = _('Threads list only').message
+    role.permissions = {
+                        'can_see_forum': True,
+                        'can_see_forum_contents': True,
+                       }
+    role.save(force_insert=True)
+
+    role = ForumRole()
+    role.name = _('Read only').message
+    role.permissions = {
+                        'can_see_forum': True,
+                        'can_see_forum_contents': True,
+                        'can_read_threads': 2,
+                        'can_see_posts_scores': 2,
+                       }
+    role.save(force_insert=True)

+ 27 - 0
misago/fixtures/privatethreadssettings.py

@@ -0,0 +1,27 @@
+from misago.utils.fixtures import load_settings_fixture, update_settings_fixture
+from misago.utils.translation import ugettext_lazy as _
+
+settings_fixture = (
+    # Threads Settings
+    ('private-threads', {
+         'name': _("Private Threads Settings"),
+         'description': _("Those settings control your forum's private threads."),
+         'settings': (
+            ('enable_private_threads', {
+                'value':        True,
+                'type':         "boolean",
+                'input':        "yesno",
+                'separator':    _("Private Threads"),
+                'name':         _("Enable Private Threads"),
+            }),
+       ),
+    }),
+)
+
+
+def load():
+    load_settings_fixture(settings_fixture)
+
+
+def update():
+    update_settings_fixture(settings_fixture)

+ 7 - 53
misago/ranks/fixtures.py → misago/fixtures/rankingsettings.py

@@ -1,10 +1,7 @@
-from misago.ranks.models import Rank
-from misago.settings.fixtures import load_settings_fixture, update_settings_fixture
-from misago.utils import ugettext_lazy as _
-from misago.utils import get_msgid
+from misago.utils.fixtures import load_settings_fixture, update_settings_fixture
+from misago.utils.translation import ugettext_lazy as _
 
-
-settings_fixtures = (
+settings_fixture = (
     # Users Ranking Settings
     ('ranking', {
         'name': _("Members Ranking"),
@@ -78,52 +75,9 @@ settings_fixtures = (
 )
 
 
-def load_fixtures():
-    load_settings_fixture(settings_fixtures)
-    Rank.objects.create(
-                        name=_("Forum Team").message,
-                        name_slug='forum-team',
-                        title=_("Forum Team").message,
-                        style='team',
-                        special=True,
-                        order=0,
-                        as_tab=True,
-                        on_index=True,
-                        )
-
-    Rank.objects.create(
-                        name=_("Most Valuable Posters").message,
-                        name_slug='most-valuable-posters',
-                        title=_("MVP").message,
-                        style='mvp',
-                        special=True,
-                        order=1,
-                        as_tab=True,
-                        )
-
-    Rank.objects.create(
-                        name=_("Lurkers").message,
-                        name_slug='lurkers',
-                        order=1,
-                        criteria="100%"
-                        )
-
-    Rank.objects.create(
-                        name=_("Members").message,
-                        name_slug='members',
-                        order=2,
-                        criteria="75%"
-                        )
-
-    Rank.objects.create(
-                        name=_("Active Members").message,
-                        name_slug='active-members',
-                        style='active',
-                        order=3,
-                        criteria="10%",
-                        as_tab=True,
-                        )
+def load():
+    load_settings_fixture(settings_fixture)
 
 
-def update_fixtures():
-    update_settings_fixture(settings_fixtures)
+def update():
+    update_settings_fixture(settings_fixture)

+ 48 - 0
misago/fixtures/ranks.py

@@ -0,0 +1,48 @@
+from misago.models import Rank
+from misago.utils.translation import ugettext_lazy as _
+
+def load():
+    Rank.objects.create(
+                        name=_("Forum Team").message,
+                        slug='forum-team',
+                        title=_("Forum Team").message,
+                        style='team',
+                        special=True,
+                        order=0,
+                        as_tab=True,
+                        on_index=True,
+                        )
+
+    Rank.objects.create(
+                        name=_("Most Valuable Posters").message,
+                        slug='most-valuable-posters',
+                        title=_("MVP").message,
+                        style='mvp',
+                        special=True,
+                        order=1,
+                        as_tab=True,
+                        )
+
+    Rank.objects.create(
+                        name=_("Top Posters").message,
+                        slug='top-posters',
+                        title="Top",
+                        style='top',
+                        order=2,
+                        criteria="10%",
+                        as_tab=True,
+                        )
+
+    Rank.objects.create(
+                        name=_("Members").message,
+                        slug='members',
+                        order=4,
+                        criteria="75%"
+                        )
+
+    Rank.objects.create(
+                        name=_("Lurkers").message,
+                        slug='lurkers',
+                        order=5,
+                        criteria="100%"
+                        )

+ 7 - 8
misago/authn/fixtures.py → misago/fixtures/signingsettings.py

@@ -1,8 +1,7 @@
-from misago.settings.fixtures import load_settings_fixture, update_settings_fixture
-from misago.utils import ugettext_lazy as _
-from misago.utils import get_msgid
+from misago.utils.fixtures import load_settings_fixture, update_settings_fixture
+from misago.utils.translation import ugettext_lazy as _
 
-settings_fixtures = (
+settings_fixture = (
     # Register and Sign-In Settings
     ('signin', {
         'name': _("Sign-In and Sessions Settings"),
@@ -43,9 +42,9 @@ settings_fixtures = (
 )
 
 
-def load_fixtures():
-    load_settings_fixture(settings_fixtures)
+def load():
+    load_settings_fixture(settings_fixture)
 
 
-def update_fixtures():
-    update_settings_fixture(settings_fixtures)
+def update():
+    update_settings_fixture(settings_fixture)

+ 8 - 9
misago/threads/fixtures.py → misago/fixtures/threadssettings.py

@@ -1,8 +1,7 @@
-from misago.settings.fixtures import load_settings_fixture, update_settings_fixture
-from misago.utils import ugettext_lazy as _
-from misago.utils import get_msgid
+from misago.utils.fixtures import load_settings_fixture, update_settings_fixture
+from misago.utils.translation import ugettext_lazy as _
 
-settings_fixtures = (
+settings_fixture = (
     # Threads Settings
     ('threads', {
          'name': _("Threads and Posts Settings"),
@@ -18,7 +17,7 @@ settings_fixtures = (
                 'description':  _('Minimal allowed thread name length.'),
             }),
             ('thread_name_max', {
-                'value':        60,
+                'value':        50,
                 'type':         "integer",
                 'input':        "text",
                 'extra':        {'min': 5, 'max': 100},
@@ -119,9 +118,9 @@ settings_fixtures = (
 )
 
 
-def load_fixtures():
-    load_settings_fixture(settings_fixtures)
+def load():
+    load_settings_fixture(settings_fixture)
 
 
-def update_fixtures():
-    update_settings_fixture(settings_fixtures)
+def update():
+    update_settings_fixture(settings_fixture)

+ 7 - 8
misago/tos/fixtures.py → misago/fixtures/tossettings.py

@@ -1,8 +1,7 @@
-from misago.settings.fixtures import load_settings_fixture, update_settings_fixture
-from misago.utils import ugettext_lazy as _
-from misago.utils import get_msgid
+from misago.utils.fixtures import load_settings_fixture, update_settings_fixture
+from misago.utils.translation import ugettext_lazy as _
 
-settings_fixtures = (
+settings_fixture = (
     # Avatars Settings
     ('tos', {
          'name': _("Forum Terms of Service"),
@@ -35,9 +34,9 @@ settings_fixtures = (
 )
 
 
-def load_fixtures():
-    load_settings_fixture(settings_fixtures)
+def load():
+    load_settings_fixture(settings_fixture)
 
 
-def update_fixtures():
-    update_settings_fixture(settings_fixtures)
+def update():
+    update_settings_fixture(settings_fixture)

+ 72 - 0
misago/fixtures/userroles.py

@@ -0,0 +1,72 @@
+from misago.models import Role
+from misago.utils.translation import ugettext_lazy as _
+
+def load():
+    role = Role(name=_("Administrator").message, _special='admin', protected=True)
+    role.permissions = {
+                        'name_changes_allowed': 5,
+                        'changes_expire': 7,
+                        'can_use_acp': True,
+                        'can_use_mcp': True,
+                        'can_use_signature': True,
+                        'allow_signature_links': True,
+                        'allow_signature_images': True,
+                        'can_search_users': True,
+                        'can_see_users_emails': True,
+                        'can_see_users_trails': True,
+                        'can_see_hidden_users': True,
+                        'can_use_private_threads': True,
+                        'can_start_private_threads': True,
+                        'can_upload_attachments_in_private_threads': True,
+                        'private_thread_attachment_size': 0,
+                        'private_thread_attachments_limit': 0,
+                        'can_invite_ignoring': True,
+                        'private_threads_mod': True,
+                        'forums': {3: 1, 5: 1, 6: 1},
+                       }
+    role.save(force_insert=True)
+    
+    role = Role(name=_("Moderator").message, _special='mod', protected=True)
+    role.permissions = {
+                        'name_changes_allowed': 3,
+                        'changes_expire': 14,
+                        'can_use_mcp': True,
+                        'can_use_signature': True,
+                        'allow_signature_links': True,
+                        'can_search_users': True,
+                        'can_see_users_emails': True,
+                        'can_see_users_trails': True,
+                        'can_see_hidden_users': True,
+                        'can_use_private_threads': True,
+                        'can_start_private_threads': True,
+                        'can_upload_attachments_in_private_threads': True,
+                        'private_thread_attachment_size': 0,
+                        'private_thread_attachments_limit': 0,
+                        'can_invite_ignoring': True,
+                        'private_threads_mod': True,
+                        'forums': {3: 1, 5: 1, 6: 1},
+                       }
+    role.save(force_insert=True)
+    
+    role = Role(name=_("Registered").message, _special='registered')
+    role.permissions = {
+                        'name_changes_allowed': 2,
+                        'can_use_signature': False,
+                        'can_search_users': True,
+                        'can_use_private_threads': True,
+                        'can_start_private_threads': True,
+                        'can_upload_attachments_in_private_threads': False,
+                        'private_thread_attachment_size': 100,
+                        'private_thread_attachments_limit': 30,
+                        'can_invite_ignoring': False,
+                        'private_threads_mod': False,
+                        'forums': {4: 3, 5: 3, 6: 3},
+                       }
+    role.save(force_insert=True)
+    
+    role = Role(name=_("Guest").message, _special='guest')
+    role.permissions = {
+                        'can_search_users': True,
+                        'forums': {4: 6, 5: 6, 6: 6},
+                       }
+    role.save(force_insert=True)

+ 14 - 0
misago/fixtures/usersmonitor.py

@@ -0,0 +1,14 @@
+from misago.utils.fixtures import load_monitor_fixture
+
+monitor_fixture = {
+                   'users': 0,
+                   'users_inactive': 0,
+                   'users_reported': 0,
+                   'last_user': None,
+                   'last_user_name': None,
+                   'last_user_slug': None,
+                  }
+
+
+def load():
+    load_monitor_fixture(monitor_fixture)

+ 4 - 176
misago/forms/__init__.py

@@ -1,176 +1,4 @@
-from django import forms
-from django.utils.html import conditional_escape, mark_safe
-from django.utils.translation import ugettext_lazy as _
-from mptt.forms import TreeNodeChoiceField
-from misago.forms.layouts import *
-from recaptcha.client.captcha import submit as recaptcha_submit
-
-class Form(forms.Form):
-    """
-    Custom form implementation extending Django Forms functionality
-    """
-    validate_repeats = []
-    repeats_errors = []
-    dont_strip = []
-    error_source = None
-    def __init__(self, data=None, file=None, request=None, *args, **kwargs):
-        self.request = request
-
-        # Extract request from first argument
-        if data != None:
-            super(Form, self).__init__(data, file, *args, **kwargs)
-        else:
-            super(Form, self).__init__(*args, **kwargs)
-
-        # Kill captcha fields
-        try:
-            if self.request.settings['bots_registration'] != 'recaptcha' or self.request.session.get('captcha_passed'):
-                del self.fields['recaptcha']
-        except KeyError:
-            pass
-        try:
-            if self.request.settings['bots_registration'] != 'qa' or self.request.session.get('captcha_passed'):
-                del self.fields['captcha_qa']
-            else:
-                # Make sure we have any questions loaded
-                self.fields['captcha_qa'].label = self.request.settings['qa_test']
-                self.fields['captcha_qa'].help_text = self.request.settings['qa_test_help']
-        except KeyError:
-            pass
-
-        # Let forms do mumbo-jumbo with fields removing
-        self.finalize_form()
-
-    def finalize_form(self):
-        pass
-
-    def full_clean(self):
-        """
-        Trim inputs and strip newlines
-        """
-        self.data = self.data.copy()
-        for key, field in self.fields.iteritems():
-            try:
-                if field.__class__.__name__ in ['ModelChoiceField', 'TreeForeignKey'] and self.data[key]:
-                    self.data[key] = int(self.data[key])
-                elif field.__class__.__name__ == 'ModelMultipleChoiceField':
-                    self.data.setlist(key, [int(x) for x in self.data.getlist(key, [])])
-                elif field.__class__.__name__ not in ['DateField', 'DateTimeField']:
-                    if not key in self.dont_strip:
-                        if field.__class__.__name__ in ['MultipleChoiceField', 'TypedMultipleChoiceField']:
-                            self.data.setlist(key, [x.strip() for x in self.data.getlist(key, [])])
-                        else:
-                            self.data[key] = self.data[key].strip()
-                    if field.__class__.__name__ in ['MultipleChoiceField', 'TypedMultipleChoiceField']:
-                        self.data.setlist(key, [x.replace("\r\n", '') for x in self.data.getlist(key, [])])
-                    elif not field.widget.__class__.__name__ in ['Textarea']:
-                        self.data[key] = self.data[key].replace("\r\n", '')
-            except (KeyError, AttributeError):
-                pass
-        super(Form, self).full_clean()
-
-    def clean(self):
-        """
-        Clean data, do magic checks and stuff
-        """
-        cleaned_data = super(Form, self).clean()
-        self._check_all()
-        return cleaned_data
-
-    def clean_recaptcha(self):
-        """
-        Test reCaptcha, scream if it went wrong
-        """
-        response = recaptcha_submit(
-                                    self.request.POST.get('recaptcha_challenge_field'),
-                                    self.request.POST.get('recaptcha_response_field'),
-                                    self.request.settings['recaptcha_private'],
-                                    self.request.session.get_ip(self.request)
-                                    ).is_valid
-        if not response:
-            raise forms.ValidationError(_("Entered words are incorrect. Please try again."))
-        self.request.session['captcha_passed'] = True
-        return ''
-
-    def clean_captcha_qa(self):
-        """
-        Test QA Captcha, scream if it went wrong
-        """
-
-        if not unicode(self.cleaned_data['captcha_qa']).lower() in (name.lower() for name in unicode(self.request.settings['qa_test_answers']).splitlines()):
-            raise forms.ValidationError(_("The answer you entered is incorrect."))
-        self.request.session['captcha_passed'] = True
-        return self.cleaned_data['captcha_qa']
-
-    def _check_all(self):
-        # Check repeated fields
-        self._check_repeats()
-        # Check CSRF, we dont allow un-csrf'd forms in Misago
-        self._check_csrf()
-        # Check if we have any errors from fields, if we do, we will set fancy form-wide error message
-        self._check_fields_errors()
-
-    def _check_repeats(self):
-        for index, repeat in enumerate(self.validate_repeats):
-            # Check empty fields
-            for field in repeat:
-                if not field in self.data:
-                    try:
-                        if len(repeat) == 2:
-                            self.errors['_'.join(repeat)] = [self.repeats_errors[index]['fill_both']]
-                        else:
-                            self.errors['_'.join(repeat)] = [self.repeats_errors[index]['fill_all']]
-                    except (IndexError, KeyError):
-                        if len(repeat) == 2:
-                            self.errors['_'.join(repeat)] = [_("You have to fill in both fields.")]
-                        else:
-                            self.errors['_'.join(repeat)] = [_("You have to fill in all fields.")]
-                    break
-
-            else:
-                # Check different fields
-                past_field = self.data[repeat[0]]
-                for field in repeat:
-                    if self.data[field] != past_field:
-                        try:
-                            self.errors['_'.join(repeat)] = [self.repeats_errors[index]['different']]
-                        except (IndexError, KeyError):
-                            self.errors['_'.join(repeat)] = [_("Entered values differ from each other.")]
-                        break
-                    past_field = self.data[field]
-
-
-    def _check_csrf(self):
-        if not self.request.csrf.request_secure(self.request):
-            raise forms.ValidationError(_("Request authorization is invalid. Please resubmit your form."))
-
-    def _check_fields_errors(self):
-        if self.errors:
-            if self.error_source and self.error_source in self.errors:
-                field_error, self.errors[self.error_source] = self.errors[self.error_source][0], []
-                raise forms.ValidationError(field_error)
-            raise forms.ValidationError(_("Form contains errors."))
-        
-    def empty_errors(self):
-        for i in self.errors:
-            self.errors[i] = []
-
-
-class YesNoSwitch(forms.CheckboxInput):
-    """
-    Custom Yes-No switch as fancier alternative to checkboxes
-    """
-    pass
-
-
-class ForumChoiceField(TreeNodeChoiceField):
-    """
-    Custom forum choice field
-    """
-    def __init__(self, *args, **kwargs):
-        kwargs['level_indicator'] = u'- - '
-        super(ForumChoiceField, self).__init__(*args, **kwargs)
-
-    def _get_level_indicator(self, obj):
-        level = getattr(obj, obj._mptt_meta.level_attr)
-        return mark_safe(conditional_escape(self.level_indicator) * (level - 1))
+from misago.forms.fields import ForumChoiceField, ReCaptchaField, QACaptchaField
+from misago.forms.forms import Form
+from misago.forms.layouts import FormLayout, FormFields, FormFieldsets
+from misago.forms.widgets import ReCaptchaWidget, YesNoSwitch

+ 30 - 0
misago/forms/fields.py

@@ -0,0 +1,30 @@
+from mptt.forms import TreeNodeChoiceField
+from recaptcha.client.captcha import API_SSL_SERVER, API_SERVER, VERIFY_SERVER
+from django.forms import fields
+from django.utils.html import conditional_escape, mark_safe
+from django.utils.translation import ugettext_lazy as _
+from misago.forms.widgets import ReCaptchaWidget
+
+class ForumChoiceField(TreeNodeChoiceField):
+    """
+    Custom forum choice field
+    """
+    def __init__(self, *args, **kwargs):
+        kwargs['level_indicator'] = u'- - '
+        super(ForumChoiceField, self).__init__(*args, **kwargs)
+
+    def _get_level_indicator(self, obj):
+        level = getattr(obj, obj._mptt_meta.level_attr)
+        return mark_safe(conditional_escape(self.level_indicator) * (level - 1))
+
+
+class ReCaptchaField(fields.CharField):
+    widget = ReCaptchaWidget
+    api_error = None
+    def __init__(self, label=_("Verification Code"), *args, **kwargs):
+        kwargs['label'], kwargs['required'] = label, False
+        super(ReCaptchaField, self).__init__(*args, **kwargs)
+
+
+class QACaptchaField(fields.CharField):
+    pass

+ 162 - 0
misago/forms/forms.py

@@ -0,0 +1,162 @@
+from recaptcha.client.captcha import submit as recaptcha_submit
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+
+class Form(forms.Form):
+    """
+    Misago-native form abstract extending Django's one with automatic trimming
+    of user input, captacha support and more accessible validation errors
+    """
+    validate_repeats = []
+    repeats_errors = []
+    dont_strip = []
+    error_source = None
+
+    def __init__(self, data=None, file=None, request=None, *args, **kwargs):
+        self.form_finalized = False
+        self.request = request
+
+        # Extract request from first arguments
+        if data != None:
+            super(Form, self).__init__(data, file, *args, **kwargs)
+        else:
+            super(Form, self).__init__(*args, **kwargs)
+
+        # Let forms do mumbo-jumbo with fields removing
+        self.ensure_finalization()
+
+        # Kill captcha fields
+        try:
+            if self.request.settings['bots_registration'] != 'recaptcha' or self.request.session.get('captcha_passed'):
+                del self.fields['recaptcha']
+        except KeyError:
+            pass
+        try:
+            if self.request.settings['bots_registration'] != 'qa' or self.request.session.get('captcha_passed'):
+                del self.fields['captcha_qa']
+            else:
+                # Make sure we have any questions loaded
+                self.fields['captcha_qa'].label = self.request.settings['qa_test']
+                self.fields['captcha_qa'].help_text = self.request.settings['qa_test_help']
+        except KeyError:
+            pass
+
+    def ensure_finalization(self):
+        if not self.form_finalized:
+            self.form_finalized = True
+            self.finalize_form()
+
+    def finalize_form(self):
+        pass
+
+    def full_clean(self):
+        """
+        Trim inputs and strip newlines
+        """
+        self.ensure_finalization()
+        self.data = self.data.copy()
+        for key, field in self.fields.iteritems():
+            try:
+                if field.__class__.__name__ in ['ModelChoiceField', 'TreeForeignKey'] and self.data[key]:
+                    self.data[key] = int(self.data[key])
+                elif field.__class__.__name__ == 'ModelMultipleChoiceField':
+                    self.data.setlist(key, [int(x) for x in self.data.getlist(key, [])])
+                elif field.__class__.__name__ not in ['DateField', 'DateTimeField']:
+                    if not key in self.dont_strip:
+                        if field.__class__.__name__ in ['MultipleChoiceField', 'TypedMultipleChoiceField']:
+                            self.data.setlist(key, [x.strip() for x in self.data.getlist(key, [])])
+                        else:
+                            self.data[key] = self.data[key].strip()
+                    if field.__class__.__name__ in ['MultipleChoiceField', 'TypedMultipleChoiceField']:
+                        self.data.setlist(key, [x.replace("\r\n", '') for x in self.data.getlist(key, [])])
+                    elif not field.widget.__class__.__name__ in ['Textarea']:
+                        self.data[key] = self.data[key].replace("\r\n", '')
+            except (KeyError, AttributeError):
+                pass
+        super(Form, self).full_clean()
+
+    def clean(self):
+        """
+        Clean data, do magic checks and stuff
+        """
+        cleaned_data = super(Form, self).clean()
+        self._check_all()
+        return cleaned_data
+
+    def clean_recaptcha(self):
+        """
+        Test reCaptcha, scream if it went wrong
+        """
+        response = recaptcha_submit(
+                                    self.request.POST.get('recaptcha_challenge_field'),
+                                    self.request.POST.get('recaptcha_response_field'),
+                                    self.request.settings['recaptcha_private'],
+                                    self.request.session.get_ip(self.request)
+                                    ).is_valid
+        if not response:
+            raise forms.ValidationError(_("Entered words are incorrect. Please try again."))
+        self.request.session['captcha_passed'] = True
+        return ''
+
+    def clean_captcha_qa(self):
+        """
+        Test QA Captcha, scream if it went wrong
+        """
+
+        if not unicode(self.cleaned_data['captcha_qa']).lower() in (name.lower() for name in unicode(self.request.settings['qa_test_answers']).splitlines()):
+            raise forms.ValidationError(_("The answer you entered is incorrect."))
+        self.request.session['captcha_passed'] = True
+        return self.cleaned_data['captcha_qa']
+
+    def _check_all(self):
+        # Check repeated fields
+        self._check_repeats()
+        # Check CSRF, we dont allow un-csrf'd forms in Misago
+        self._check_csrf()
+        # Check if we have any errors from fields, if we do, we will set fancy form-wide error message
+        self._check_fields_errors()
+
+    def _check_repeats(self):
+        for index, repeat in enumerate(self.validate_repeats):
+            # Check empty fields
+            for field in repeat:
+                if not field in self.data:
+                    try:
+                        if len(repeat) == 2:
+                            self.errors['_'.join(repeat)] = [self.repeats_errors[index]['fill_both']]
+                        else:
+                            self.errors['_'.join(repeat)] = [self.repeats_errors[index]['fill_all']]
+                    except (IndexError, KeyError):
+                        if len(repeat) == 2:
+                            self.errors['_'.join(repeat)] = [_("You have to fill in both fields.")]
+                        else:
+                            self.errors['_'.join(repeat)] = [_("You have to fill in all fields.")]
+                    break
+
+            else:
+                # Check different fields
+                past_field = self.data[repeat[0]]
+                for field in repeat:
+                    if self.data[field] != past_field:
+                        try:
+                            self.errors['_'.join(repeat)] = [self.repeats_errors[index]['different']]
+                        except (IndexError, KeyError):
+                            self.errors['_'.join(repeat)] = [_("Entered values differ from each other.")]
+                        break
+                    past_field = self.data[field]
+
+
+    def _check_csrf(self):
+        if not self.request.csrf.request_secure(self.request):
+            raise forms.ValidationError(_("Request authorization is invalid. Please resubmit your form."))
+
+    def _check_fields_errors(self):
+        if self.errors:
+            if self.error_source and self.error_source in self.errors:
+                field_error, self.errors[self.error_source] = self.errors[self.error_source][0], []
+                raise forms.ValidationError(field_error)
+            raise forms.ValidationError(_("Form contains errors."))
+        
+    def empty_errors(self):
+        for i in self.errors:
+            self.errors[i] = []

+ 14 - 6
misago/forms/layouts.py

@@ -1,16 +1,28 @@
 from UserDict import IterableUserDict
+from recaptcha.client.captcha import displayhtml
 from django.utils import formats
 
 class FormLayout(object):
+    """
+    Conglomelate of fields and fieldsets describing form structure
+    """
     def __init__(self, form, fieldsets=False):
         scaffold_fields = FormFields(form)
         scaffold_fieldsets = FormFieldsets(form, scaffold_fields.fields, fieldsets)
 
         self.multipart_form = scaffold_fields.multipart_form
         self.fieldsets = scaffold_fieldsets.fieldsets
-        self.fields = scaffold_fields.fields
         self.hidden = scaffold_fields.hidden
 
+        if self.fieldsets:
+            self.fields = {}
+            for fieldset in self.fieldsets:
+                for field in fieldset['fields']:
+                    self.fields[field['id']] = field
+        else:
+            self.fields = scaffold_fields.fields
+
+
 class FormFields(object):
     """
     Hydrator that builds fields list from form and blueprint
@@ -98,7 +110,6 @@ class FormFields(object):
 
             # ReCaptcha      
             if widget_name == 'ReCaptchaWidget':
-                from recaptcha.client.captcha import displayhtml
                 blueprint['widget'] = 'recaptcha'
                 blueprint['attrs'] = {'html': displayhtml(
                                                           form.request.settings['recaptcha_public'],
@@ -164,7 +175,7 @@ class FormFields(object):
             # Select
             if widget_name == 'Select':
                 blueprint['widget'] = 'select'
-                if not blueprint['value']:
+                if not blueprint['has_value']:
                     blueprint['value'] = None
 
             # NullBooleanSelect
@@ -178,8 +189,6 @@ class FormFields(object):
             # RadioSelect
             if widget_name == 'RadioSelect':
                 blueprint['widget'] = 'radio_select'
-                if not blueprint['value']:
-                    blueprint['value'] = u''
 
             # CheckboxSelectMultiple
             if widget_name == 'CheckboxSelectMultiple':
@@ -296,4 +305,3 @@ class FormFieldsets(object):
                 if fieldset['fields']:
                     self.fieldsets.append(fieldset)
             self.fieldsets[-1]['last'] = True
-

+ 8 - 0
misago/forms/widgets.py

@@ -0,0 +1,8 @@
+from django import forms
+
+class ReCaptchaWidget(forms.TextInput):
+    pass
+
+
+class YesNoSwitch(forms.CheckboxInput):
+    pass

+ 0 - 114
misago/forumroles/fixtures.py

@@ -1,114 +0,0 @@
-from misago.forumroles.models import ForumRole
-from misago.utils import ugettext_lazy as _
-from misago.utils import get_msgid
-
-def load_fixtures():
-    role = ForumRole()
-    role.name = _('Full Access').message
-    role.set_permissions({
-                          'can_see_forum': True,
-                          'can_see_forum_contents': True,
-                          'can_read_threads': 2,
-                          'can_start_threads': 2,
-                          'can_edit_own_threads': True,
-                          'can_soft_delete_own_threads': True,
-                          'can_write_posts': 2,
-                          'can_edit_own_posts': True,
-                          'can_soft_delete_own_posts': True,
-                          'can_upvote_posts': True,
-                          'can_downvote_posts': True,
-                          'can_see_posts_scores': 2,
-                          'can_see_votes': True,
-                          'can_make_polls': True,
-                          'can_vote_in_polls': True,
-                          'can_see_poll_votes': True,
-                          'can_see_attachments': True,
-                          'can_upload_attachments': True,
-                          'can_download_attachments': True,
-                          'attachment_size': 5000,
-                          'attachment_limit': 15,
-                          'can_approve': True,
-                          'can_edit_labels': True,
-                          'can_see_changelog': True,
-                          'can_pin_threads': 2,
-                          'can_edit_threads_posts': True,
-                          'can_move_threads_posts': True,
-                          'can_close_threads': True,
-                          'can_protect_posts': True,
-                          'can_delete_threads': 2,
-                          'can_delete_posts': 2,
-                          'can_delete_polls': 2,
-                          'can_delete_attachments': True,
-                          })
-    role.save(force_insert=True)
-
-    role = ForumRole()
-    role.name = _('Standard Access and Upload').message
-    role.set_permissions({
-                          'can_see_forum': True,
-                          'can_see_forum_contents': True,
-                          'can_read_threads': 2,
-                          'can_start_threads': 2,
-                          'can_edit_own_threads': True,
-                          'can_write_posts': 2,
-                          'can_edit_own_posts': True,
-                          'can_soft_delete_own_posts': True,
-                          'can_upvote_posts': True,
-                          'can_downvote_posts': True,
-                          'can_see_posts_scores': 1,
-                          'can_make_polls': True,
-                          'can_vote_in_polls': True,
-                          'can_upload_attachments': True,
-                          'can_download_attachments': True,
-                          'attachment_size': 100,
-                          'attachment_limit': 3,
-                          })
-    role.save(force_insert=True)
-
-    role = ForumRole()
-    role.name = _('Standard Access').message
-    role.set_permissions({
-                          'can_see_forum': True,
-                          'can_see_forum_contents': True,
-                          'can_read_threads': 2,
-                          'can_start_threads': 2,
-                          'can_edit_own_threads': True,
-                          'can_write_posts': 2,
-                          'can_edit_own_posts': True,
-                          'can_soft_delete_own_posts': True,
-                          'can_upvote_posts': True,
-                          'can_downvote_posts': True,
-                          'can_see_posts_scores': 1,
-                          'can_make_polls': True,
-                          'can_vote_in_polls': True,
-                          'can_download_attachments': True,
-                          })
-    role.save(force_insert=True)
-
-    role = ForumRole()
-    role.name = _('Read and Download').message
-    role.set_permissions({
-                          'can_see_forum': True,
-                          'can_see_forum_contents': True,
-                          'can_read_threads': 2,
-                          'can_download_attachments': True,
-                          'can_see_posts_scores': 1,
-                          })
-    role.save(force_insert=True)
-
-    role = ForumRole()
-    role.name = _('Threads list only').message
-    role.set_permissions({
-                          'can_see_forum': True,
-                          'can_see_forum_contents': True,
-                          })
-    role.save(force_insert=True)
-
-    role = ForumRole()
-    role.name = _('Read only').message
-    role.set_permissions({
-                          'can_see_forum': True,
-                          'can_see_forum_contents': True,
-                          'can_read_threads': 2,
-                          })
-    role.save(force_insert=True)

+ 0 - 34
misago/forumroles/migrations/0001_initial.py

@@ -1,34 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'ForumRole'
-        db.create_table(u'forumroles_forumrole', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('permissions', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-        ))
-        db.send_create_signal(u'forumroles', ['ForumRole'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'ForumRole'
-        db.delete_table(u'forumroles_forumrole')
-
-
-    models = {
-        u'forumroles.forumrole': {
-            'Meta': {'object_name': 'ForumRole'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
-        }
-    }
-
-    complete_apps = ['forumroles']

+ 0 - 235
misago/forums/migrations/0001_initial.py

@@ -1,235 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Forum'
-        db.create_table(u'forums_forum', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('parent', self.gf('mptt.fields.TreeForeignKey')(blank=True, related_name='children', null=True, to=orm['forums.Forum'])),
-            ('type', self.gf('django.db.models.fields.CharField')(max_length=12)),
-            ('token', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('slug', self.gf('django.db.models.fields.SlugField')(max_length=255)),
-            ('description', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('description_preparsed', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('threads', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('threads_delta', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('posts', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('posts_delta', self.gf('django.db.models.fields.IntegerField')(default=0)),
-            ('redirects', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('redirects_delta', self.gf('django.db.models.fields.IntegerField')(default=0)),
-            ('last_thread', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['threads.Thread'])),
-            ('last_thread_name', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('last_thread_slug', self.gf('django.db.models.fields.SlugField')(max_length=255, null=True, blank=True)),
-            ('last_thread_date', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
-            ('last_poster', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['users.User'])),
-            ('last_poster_name', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('last_poster_slug', self.gf('django.db.models.fields.SlugField')(max_length=255, null=True, blank=True)),
-            ('last_poster_style', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('prune_start', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('prune_last', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('redirect', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('attrs', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('show_details', self.gf('django.db.models.fields.BooleanField')(default=True)),
-            ('style', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('closed', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('lft', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
-            ('rght', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
-            ('tree_id', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
-            ('level', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
-        ))
-        db.send_create_signal(u'forums', ['Forum'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Forum'
-        db.delete_table(u'forums_forum')
-
-
-    models = {
-        u'forums.forum': {
-            'Meta': {'object_name': 'Forum'},
-            'attrs': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'description_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_thread': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Thread']"}),
-            'last_thread_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_thread_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_thread_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'parent': ('mptt.fields.TreeForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': u"orm['forums.Forum']"}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'posts_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'prune_last': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'prune_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'redirect': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'redirects': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'redirects_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'show_details': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'type': ('django.db.models.fields.CharField', [], {'max_length': '12'})
-        },
-        u'ranks.rank': {
-            'Meta': {'object_name': 'Rank'},
-            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'roles.role': {
-            'Meta': {'object_name': 'Role'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'threads.post': {
-            'Meta': {'object_name': 'Post'},
-            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'checkpoints': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'date': ('django.db.models.fields.DateTimeField', [], {}),
-            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'edit_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'edit_reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edit_user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'edit_user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edit_user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edits': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'mentions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'mention_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'merge': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'post': ('django.db.models.fields.TextField', [], {}),
-            'post_preparsed': ('django.db.models.fields.TextField', [], {}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'reported': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Thread']"}),
-            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
-        },
-        u'threads.thread': {
-            'Meta': {'object_name': 'Thread'},
-            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last': ('django.db.models.fields.DateTimeField', [], {}),
-            'last_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Post']"}),
-            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'merges': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'replies': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_deleted': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_moderated': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_reported': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'score': ('django.db.models.fields.PositiveIntegerField', [], {'default': '30'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'start': ('django.db.models.fields.DateTimeField', [], {}),
-            'start_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Post']"}),
-            'start_poster': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'start_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'start_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'start_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'type': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'weight': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        },
-        u'users.user': {
-            'Meta': {'object_name': 'User'},
-            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'allow_pms': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
-            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
-            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
-            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
-            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ranks.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['roles.Role']", 'symmetrical': 'False'}),
-            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
-            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        }
-    }
-
-    complete_apps = ['forums']

+ 0 - 4
misago/forums/signals.py

@@ -1,4 +0,0 @@
-import django.dispatch
-
-move_forum_content = django.dispatch.Signal(providing_args=["move_to"])
-delete_forum_content = django.dispatch.Signal()

+ 0 - 0
misago/ranks/management/commands/__init__.py → misago/management/__init__.py


+ 0 - 0
misago/ranks/migrations/__init__.py → misago/management/commands/__init__.py


+ 2 - 2
misago/setup/management/commands/about.py → misago/management/commands/about.py

@@ -1,6 +1,6 @@
 from django.core.management.base import BaseCommand, CommandError
 from django.utils import timezone
-from misago import get_version
+from misago import __version__
 
 class Command(BaseCommand):
     """
@@ -16,7 +16,7 @@ class Command(BaseCommand):
         self.stdout.write('                      /_/ /_/ /_/_/____/\__,_/\__, /\____/ \n')
         self.stdout.write('                                             /____/\n')
         self.stdout.write('\n')
-        self.stdout.write('                    Your community is powered by Misago v.%s' % get_version())
+        self.stdout.write('                    Your community is powered by Misago v.%s' % __version__)
         self.stdout.write('\n              For help and feedback visit http://misago-project.org')
         self.stdout.write('\n\n')
         self.stdout.write('================================================================================')

+ 2 - 3
misago/users/management/commands/adduser.py → misago/management/commands/adduser.py

@@ -2,8 +2,7 @@ from django.core.exceptions import ValidationError, NON_FIELD_ERRORS
 from django.core.management.base import BaseCommand, CommandError
 from django.utils import timezone
 from optparse import make_option
-from misago.roles.models import Role
-from misago.users.models import User
+from misago.models import Role, User
 
 class Command(BaseCommand):
     args = 'username email password'
@@ -28,7 +27,7 @@ class Command(BaseCommand):
 
         # Set admin role
         if options['admin']:
-            new_user.roles.add(Role.objects.get(token='admin'))
+            new_user.roles.add(Role.objects.get(_special='admin'))
             new_user.make_acl_key(True)
             new_user.save(force_update=True)
 

+ 1 - 1
misago/alerts/management/commands/clearalerts.py → misago/management/commands/clearalerts.py

@@ -1,7 +1,7 @@
 from datetime import timedelta
 from django.core.management.base import BaseCommand
 from django.utils import timezone
-from misago.alerts.models import Alert
+from misago.models import Alert
 
 class Command(BaseCommand):
     """

+ 1 - 1
misago/bruteforce/management/commands/clearattempts.py → misago/management/commands/clearattempts.py

@@ -1,6 +1,6 @@
 from datetime import timedelta
 from django.utils import timezone
-from misago.bruteforce.models import SignInAttempt
+from misago.models import SignInAttempt
 
 class Command(BaseCommand):
     """

+ 1 - 1
misago/sessions/management/commands/clearsessions.py → misago/management/commands/clearsessions.py

@@ -2,7 +2,7 @@ from datetime import timedelta
 from django.conf import settings
 from django.core.management.base import BaseCommand
 from django.utils import timezone
-from misago.sessions.models import Session
+from misago.models import Session
 
 class Command(BaseCommand):
     """

+ 1 - 1
misago/sessions/management/commands/cleartokens.py → misago/management/commands/cleartokens.py

@@ -1,7 +1,7 @@
 from datetime import timedelta
 from django.core.management.base import BaseCommand
 from django.utils import timezone
-from misago.sessions.models import Token
+from misago.models import Token
 
 class Command(BaseCommand):
     """

+ 3 - 3
misago/readstracker/management/commands/cleartracker.py → misago/management/commands/cleartracker.py

@@ -2,7 +2,7 @@ from datetime import timedelta
 from django.conf import settings
 from django.core.management.base import BaseCommand
 from django.utils import timezone
-from misago.readstracker.models import ForumRecord, ThreadRecord
+from misago.models import ForumRead, ThreadRead
 
 class Command(BaseCommand):
     """
@@ -10,6 +10,6 @@ class Command(BaseCommand):
     """
     help = 'Clears Reads Tracker memory'
     def handle(self, *args, **options):
-        ForumRecord.objects.filter(updated__lte=timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)).delete()
-        ThreadRecord.objects.filter(updated__lte=timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)).delete()
+        ForumRead.objects.filter(updated__lte=timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)).delete()
+        ThreadRead.objects.filter(updated__lte=timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)).delete()
         self.stdout.write('Reads tracker has been cleared.\n')        

+ 9 - 0
misago/management/commands/forcepdssync.py

@@ -0,0 +1,9 @@
+from django.core.management.base import BaseCommand
+from misago.models import User
+
+class Command(BaseCommand):
+    help = 'Updates unread Private Threads counters update for all users'
+
+    def handle(self, *args, **options):
+        User.objects.update(sync_pds=True)
+        self.stdout.write('\nUsers accounts were set to sync unread private threads stat on next visit.\n')

+ 1 - 1
misago/users/management/commands/genavatars.py → misago/management/commands/genavatars.py

@@ -6,7 +6,7 @@ try:
     has_pil = True
 except ImportError:
     has_pil = False
-from misago.users.models import User
+from misago.models import User
 from misago.utils.avatars import resizeimage
 
 class Command(BaseCommand):

+ 288 - 0
misago/management/commands/migratefrom01.py

@@ -0,0 +1,288 @@
+from django.conf import settings
+from django.core.management import CommandError
+from django.core.management.base import BaseCommand
+from django.db import connections
+from misago.models import *
+
+class Command(BaseCommand):
+    """
+    Small utility that migrates data from Misago 0.1 instalation to 0.2
+    """
+    help = 'Updates Popular Threads ranking'
+    def handle(self, *args, **options):
+        self.cursor = connections['deprecated'].cursor()
+        self.stdout.write('\nBeginning migration from Misago 0.1...\n')
+
+        self.mig_settings()
+        self.mig_monitor()
+
+        self.users = {}
+        self.forums = {}
+        self.threads = {}
+
+        self.mig_users()
+        self.mig_users_roles()
+        self.mig_users_relations()
+        
+        Karma.objects.all().delete()
+        Change.objects.all().delete()
+        Checkpoint.objects.all().delete()
+        Post.objects.all().delete()
+        Thread.objects.all().delete()
+
+        self.mig_forums()
+        
+        self.mig_threads()
+        self.mig_posts()
+        self.mig_subs()
+
+        self.sync_threads()
+        self.sync_forums()
+        
+        self.stdout.write('\nData was migrated.\n')
+
+    def mig_settings(self):
+        self.stdout.write('Migrating Database Settings...')
+        self.cursor.execute("SELECT setting, value FROM settings_setting");
+        for row in self.dictfetchall():
+            Setting.objects.filter(setting=row['setting']).update(value=row['value'])
+
+    def mig_monitor(self):
+        self.stdout.write('Migrating Forum Monitor...')
+        self.cursor.execute("SELECT id, value, updated FROM monitor_item");
+        for row in self.dictfetchall():
+            MonitorItem.objects.filter(id=row['id']).update(value=row['value'], updated=row['updated'])
+
+    def mig_users(self):
+        User.objects.all().delete()
+        self.stdout.write('Migrating Users...')
+        self.cursor.execute("SELECT * FROM users_user");
+        for row in self.dictfetchall():
+            self.users[row['id']] = User.objects.create(
+                                                        username=row['username'],
+                                                        username_slug=row['username_slug'],
+                                                        email=row['email'],
+                                                        email_hash=row['email_hash'],
+                                                        password=row['password'],
+                                                        password_date=row['password_date'],
+                                                        avatar_type=row['avatar_type'],
+                                                        avatar_image=row['avatar_image'],
+                                                        avatar_original=row['avatar_original'],
+                                                        avatar_temp=row['avatar_temp'],
+                                                        signature=row['signature'],
+                                                        signature_preparsed=row['signature_preparsed'],
+                                                        join_date=row['join_date'],
+                                                        join_ip=row['join_ip'],
+                                                        join_agent=row['join_agent'],
+                                                        last_date=row['last_date'],
+                                                        last_ip=row['last_ip'],
+                                                        last_agent=row['last_agent'],
+                                                        hide_activity=row['hide_activity'],
+                                                        subscribe_start=row['subscribe_start'],
+                                                        subscribe_reply=row['subscribe_reply'],
+                                                        receive_newsletters=row['receive_newsletters'],
+                                                        threads=row['threads'],
+                                                        posts=row['posts'],
+                                                        votes=row['votes'],
+                                                        karma_given_p=row['karma_given_p'],
+                                                        karma_given_n=row['karma_given_n'],
+                                                        karma_p=row['karma_p'],
+                                                        karma_n=row['karma_n'],
+                                                        following=row['following'],
+                                                        followers=row['followers'],
+                                                        score=row['score'],
+                                                        ranking=row['ranking'],
+                                                        last_sync=row['last_sync'],
+                                                        title=row['title'],
+                                                        last_post=row['last_post'],
+                                                        last_search=row['last_search'],
+                                                        alerts=row['alerts'],
+                                                        alerts_date=row['alerts_date'],
+                                                        activation=row['activation'],
+                                                        token=row['token'],
+                                                        avatar_ban=row['avatar_ban'],
+                                                        avatar_ban_reason_user=row['avatar_ban_reason_user'],
+                                                        avatar_ban_reason_admin=row['avatar_ban_reason_admin'],
+                                                        signature_ban=row['signature_ban'],
+                                                        signature_ban_reason_user=row['signature_ban_reason_user'],
+                                                        signature_ban_reason_admin=row['signature_ban_reason_admin'],
+                                                        timezone=row['timezone'],
+                                                        is_team=row['is_team'],
+                                                        acl_key=row['acl_key'],
+                                                        )
+
+    def mig_users_roles(self):
+        self.stdout.write('Migrating Users Roles...')
+        self.cursor.execute("SELECT * FROM users_user_roles");
+        for row in self.dictfetchall():
+            self.users[row['user_id']].roles.add(Role.objects.get(id=row['role_id']))
+
+    def mig_users_relations(self):
+        self.stdout.write('Migrating Users Relations...')
+        self.cursor.execute("SELECT * FROM users_user_follows");
+        for row in self.dictfetchall():
+            self.users[row['from_user_id']].follows.add(self.users[row['to_user_id']])
+        self.cursor.execute("SELECT * FROM users_user_ignores");
+        for row in self.dictfetchall():
+            self.users[row['from_user_id']].ignores.add(self.users[row['to_user_id']])
+
+    def mig_forums(self):
+        self.stdout.write('Migrating Forums...')
+        self.forums[4] = Forum.objects.get(special='root')
+        for forum in self.forums[4].get_descendants():
+            forum.delete()
+        self.forums[4] = Forum.objects.get(special='root')
+        self.cursor.execute("SELECT * FROM forums_forum WHERE level > 0 ORDER BY lft");
+        for row in self.dictfetchall():
+            self.forums[row['id']] = Forum(
+                                           type=row['type'],
+                                           special=row['token'],
+                                           name=row['name'],
+                                           slug=row['slug'],
+                                           description=row['description'],
+                                           description_preparsed=row['description_preparsed'],
+                                           redirect=row['redirect'],
+                                           attrs=row['attrs'],
+                                           show_details=row['show_details'],
+                                           style=row['style'],
+                                           closed=row['closed'],
+                                           )
+            self.forums[row['id']].insert_at(self.forums[row['parent_id']], position='last-child', save=True)
+            Forum.objects.populate_tree(True)
+
+    def mig_threads(self):
+        self.stdout.write('Migrating Threads...')
+        self.cursor.execute("SELECT * FROM threads_thread");
+        for row in self.dictfetchall():
+            self.threads[row['id']] = Thread.objects.create(                
+                                                            forum=self.forums[row['forum_id']],
+                                                            weight=row['weight'],
+                                                            name=row['name'],
+                                                            slug=row['slug'],
+                                                            merges=row['merges'],
+                                                            score=row['score'],
+                                                            upvotes=row['upvotes'],
+                                                            downvotes=row['downvotes'],
+                                                            start=row['start'],
+                                                            start_poster_name='a',
+                                                            start_poster_slug='a',
+                                                            last=row['last'],
+                                                            deleted=row['deleted'],
+                                                            closed=row['closed'],
+                                                            )
+
+    def mig_posts(self):
+        self.stdout.write('Migrating Posts...')
+        self.cursor.execute("SELECT * FROM threads_post");
+        for row in self.dictfetchall():
+            post = Post.objects.create(
+                                       forum=self.forums[row['forum_id']],
+                                       thread=self.threads[row['thread_id']],
+                                       merge=row['merge'],
+                                       user=(self.users[row['user_id']] if row['user_id'] else None),
+                                       user_name=row['user_name'],
+                                       ip=row['ip'],
+                                       agent=row['agent'],
+                                       post=row['post'],
+                                       post_preparsed=row['post_preparsed'],
+                                       upvotes=row['upvotes'],
+                                       downvotes=row['downvotes'],
+                                       checkpoints=row['checkpoints'],
+                                       date=row['date'],
+                                       edits=row['edits'],
+                                       edit_date=row['edit_date'],
+                                       edit_reason=row['edit_reason'],
+                                       edit_user=(self.users[row['edit_user_id']] if row['edit_user_id'] else None),
+                                       edit_user_name=row['edit_user_name'],
+                                       edit_user_slug=row['edit_user_slug'],
+                                       reported=row['reported'],
+                                       moderated=row['moderated'],
+                                       deleted=row['deleted'],
+                                       protected=row['protected'],
+                                       )
+
+            # Migrate post checkpoints
+            self.cursor.execute("SELECT * FROM threads_checkpoint WHERE post_id = %s" % row['id']);
+            for related in self.dictfetchall():
+                Checkpoint.objects.create(
+                                          forum=self.forums[row['forum_id']],
+                                          thread=self.threads[row['thread_id']],
+                                          post=post,
+                                          action=related['action'],
+                                          user=(self.users[related['user_id']] if related['user_id'] else None),
+                                          user_name=related['user_name'],
+                                          user_slug=related['user_slug'],
+                                          date=related['date'],
+                                          ip=related['ip'],
+                                          agent=related['agent'],
+                                          )
+
+            # Migrate post edits
+            self.cursor.execute("SELECT * FROM threads_change WHERE post_id = %s" % row['id']);
+            for related in self.dictfetchall():
+                Change.objects.create(
+                                      forum=self.forums[row['forum_id']],
+                                      thread=self.threads[row['thread_id']],
+                                      post=post,
+                                      user=(self.users[related['user_id']] if related['user_id'] else None),
+                                      user_name=related['user_name'],
+                                      user_slug=related['user_slug'],
+                                      date=related['date'],
+                                      ip=related['ip'],
+                                      agent=related['agent'],
+                                      reason=related['reason'],
+                                      thread_name_new=related['thread_name_new'],
+                                      thread_name_old=related['thread_name_old'],
+                                      post_content=related['post_content'],
+                                      size=related['size'],
+                                      change=related['change'],
+                                      )
+
+            # Migrate post karmas
+            self.cursor.execute("SELECT * FROM threads_karma WHERE post_id = %s" % row['id']);
+            for related in self.dictfetchall():
+                Karma.objects.create(
+                                     forum=self.forums[row['forum_id']],
+                                     thread=self.threads[row['thread_id']],
+                                     post=post,
+                                     user=(self.users[related['user_id']] if related['user_id'] else None),
+                                     user_name=related['user_name'],
+                                     user_slug=related['user_slug'],
+                                     date=related['date'],
+                                     ip=related['ip'],
+                                     agent=related['agent'],
+                                     score=related['score'],
+                                     )
+
+            # Migrate mentions
+            self.cursor.execute("SELECT * FROM threads_post_mentions WHERE post_id = %s" % row['id']);
+            for related in self.dictfetchall():
+                post.mentions.add(self.users[related['user_id']])
+
+    def mig_subs(self):
+        self.stdout.write('Migrating Subscribtions...')
+        self.cursor.execute("SELECT * FROM watcher_threadwatch");
+        for row in self.dictfetchall():
+            WatchedThread.objects.create( 
+                                         user=self.users[row['user_id']],
+                                         forum=self.forums[row['forum_id']],
+                                         thread=self.threads[row['thread_id']],
+                                         last_read=row['last_read'],
+                                         email=row['email'],
+                                         )
+
+    def sync_threads(self):
+        self.stdout.write('Synchronising Threads...')
+        for thread in Thread.objects.all():
+            thread.sync()
+            thread.save(force_update=True)
+
+    def sync_forums(self):
+        self.stdout.write('Synchronising Forums...')
+        for forum in Forum.objects.all():
+            forum.sync()
+            forum.save(force_update=True)
+
+    def dictfetchall(self):
+        desc = self.cursor.description
+        return [dict(zip([col[0] for col in desc], row)) for row in self.cursor.fetchall()]

+ 25 - 0
misago/management/commands/pruneforums.py

@@ -0,0 +1,25 @@
+from datetime import timedelta
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+from misago.models import Forum, Thread
+
+class Command(BaseCommand):
+    """
+    This command is intended to work as CRON job fired every few days to run forums pruning policies
+    """
+    help = 'Updates Popular Threads ranking'
+    def handle(self, *args, **options):
+        for forum in Forum.objects.all():
+            deleted = 0
+            if forum.prune_start:
+                for thread in forum.thread_set.filter(weight=0).filter(start__lte=timezone.now() - timedelta(days=forum.prune_start)):
+                    thread.delete()
+                    deleted += 1
+            if forum.prune_last:
+                for thread in forum.thread_set.filter(weight=0).filter(start__lte=timezone.now() - timedelta(days=forum.prune_last)):
+                    thread.delete()
+                    deleted += 1
+            if deleted:
+                forum.sync()
+                forum.save(force_update=True)
+        self.stdout.write('Forums were pruned.\n')

+ 32 - 0
misago/management/commands/startmisago.py

@@ -0,0 +1,32 @@
+from optparse import make_option
+from django.core.management import call_command
+from django.core.management.base import BaseCommand, CommandError
+
+class Command(BaseCommand):
+    """
+    Builds Misago database from scratch
+    """
+    help = 'Install Misago to database'
+    option_list = BaseCommand.option_list + (
+        make_option('--quiet',
+            action='store_true',
+            dest='quiet',
+            default=False,
+            help='Dont display output from this message'),
+        )
+    
+    def handle(self, *args, **options):
+        if not options['quiet']:
+            self.stdout.write('\nInstalling Misago to database...')
+
+        if options['quiet']:
+            call_command('syncdb', verbosity=0)
+            call_command('migrate', verbosity=0)
+            call_command('syncfixtures', quiet=1)
+        else:
+            call_command('syncdb')
+            call_command('migrate')
+            call_command('syncfixtures')
+
+        if not options['quiet']:
+            self.stdout.write('\nInstallation complete! Don\'t forget to run adduser to create first admin!\n')

+ 1 - 1
misago/forums/management/commands/syncdeltas.py → misago/management/commands/syncdeltas.py

@@ -1,6 +1,6 @@
 from django.core.management.base import BaseCommand
 from django.db.models import F
-from misago.forums.models import Forum
+from misago.models import Forum
 
 class Command(BaseCommand):
     """

+ 52 - 0
misago/management/commands/syncfixtures.py

@@ -0,0 +1,52 @@
+from optparse import make_option
+import traceback
+import os.path
+import pkgutil
+from django.core.management.base import BaseCommand
+from misago.models import Fixture
+from misago.utils.fixtures import load_fixture, update_fixture
+import misago.fixtures
+
+class Command(BaseCommand):
+    """
+    Loads Misago fixtures
+    """
+    help = 'Load Misago fixtures'
+    option_list = BaseCommand.option_list + (
+        make_option('--quiet',
+            action='store_true',
+            dest='quiet',
+            default=False,
+            help='Dont display output from this message'),
+        )
+    
+    def handle(self, *args, **options):
+        if not options['quiet']:
+            self.stdout.write('\nLoading data from fixtures...')
+            
+        fixture_data = {}
+        for fixture in Fixture.objects.all():
+            fixture_data[fixture.name] = fixture
+
+        loaded = 0
+        updated = 0
+        
+        fixtures_path = os.path.dirname(misago.fixtures.__file__)
+        try:
+            for _, name, _ in pkgutil.iter_modules([fixtures_path]):
+                if name in fixture_data:
+                    if update_fixture('misago.fixtures.' + name):
+                        updated += 1
+                        if not options['quiet']:
+                            self.stdout.write('Updating "%s" fixture...' % name)
+                else:
+                    if load_fixture('misago.fixtures.' + name):
+                        loaded += 1
+                        Fixture.objects.create(name=name)
+                        if not options['quiet']:
+                            self.stdout.write('Loading "%s" fixture...' % name)
+        except:
+            self.stderr.write(traceback.format_exc())
+
+        if not options['quiet']:
+            self.stdout.write('\nLoaded %s fixtures and updated %s fixtures.\n' % (loaded, updated))

+ 2 - 2
misago/users/management/commands/syncusermonitor.py → misago/management/commands/syncusermonitor.py

@@ -1,8 +1,8 @@
 from django.core.management.base import BaseCommand
 from django.utils import timezone
 from optparse import make_option
-from misago.monitor.monitor import Monitor
-from misago.users.models import User
+from misago.monitor import Monitor
+from misago.models import User
 
 class Command(BaseCommand):
     help = 'Updates forum monitor to contain to date user information'

+ 1 - 1
misago/setup/management/commands/updmisago.py → misago/management/commands/updatemisago.py

@@ -10,5 +10,5 @@ class Command(BaseCommand):
     def handle(self, *args, **options):
         self.stdout.write('\nUpdating Misago database to latest version...')
         call_command('migrate')
-        call_command('initdata')
+        call_command('syncfixtures')
         self.stdout.write('\nUpdate complete!\n')

+ 4 - 5
misago/ranks/management/commands/updateranking.py → misago/management/commands/updateranking.py

@@ -1,8 +1,7 @@
 from django.core.management.base import BaseCommand, CommandError
 from django.db.models import F
-from misago.settings.settings import Settings
-from misago.ranks.models import Rank
-from misago.users.models import User
+from misago.dbsettings import DBSettings
+from misago.models import Rank, User
 
 class Command(BaseCommand):
     """
@@ -20,7 +19,7 @@ class Command(BaseCommand):
 
         # Update Ranking
         defaulted_ranks = False
-        for rank in Rank.objects.filter(special=0).order_by('order'):
+        for rank in Rank.objects.filter(special=0).order_by('-order'):
             if defaulted_ranks:
                 # Set ranks according to ranking
                 rank.assign_rank(users_total, special_ranks)
@@ -30,7 +29,7 @@ class Command(BaseCommand):
                 defaulted_ranks = True
 
         # Inflate scores
-        settings = Settings()
+        settings = DBSettings()
         inflation = float(100 - settings['ranking_inflation']) / 100
         User.objects.all().update(score=F('score') * inflation, ranking=0)
 

+ 3 - 3
misago/threads/management/commands/updatethreadranking.py → misago/management/commands/updatethreadranking.py

@@ -1,7 +1,7 @@
 from django.core.management.base import BaseCommand
 from django.db.models import F
-from misago.settings.settings import Settings
-from misago.threads.models import Thread
+from misago.dbsettings import DBSettings
+from misago.models import Thread
 
 class Command(BaseCommand):
     """
@@ -9,7 +9,7 @@ class Command(BaseCommand):
     """
     help = 'Updates Popular Threads ranking'
     def handle(self, *args, **options):
-        settings = Settings()
+        settings = DBSettings()
         if settings['thread_ranking_inflation'] > 0:
             inflation = float(100 - settings['thread_ranking_inflation']) / 100
             Thread.objects.all().update(score=F('score') * inflation)

+ 3 - 2
misago/markdown/extensions/mentions.py

@@ -2,12 +2,13 @@ import re
 import markdown
 from markdown.util import etree
 from django.core.urlresolvers import reverse
-from misago.users.models import User
-from misago.utils import slugify
+from misago.models import User
+from misago.utils.strings import slugify
 
 # Global vars
 MENTION_RE = re.compile(r'([^\w]?)@(?P<username>(\w)+)', re.UNICODE)
 
+
 class MentionsExtension(markdown.Extension):
     def extendMarkdown(self, md):
         md.mentions = {}

+ 13 - 0
misago/markdown/extensions/strikethrough.py

@@ -0,0 +1,13 @@
+import re
+import markdown
+from markdown.inlinepatterns import SimpleTagPattern
+
+# Global vars
+STRIKETHROUGH_RE = r'~~(.+?)~~'
+
+class StrikethroughExtension(markdown.Extension):
+    def extendMarkdown(self, md):
+        md.registerExtension(self)
+        md.inlinePatterns.add('mi_strikethrough',
+                              SimpleTagPattern(STRIKETHROUGH_RE, 'del'),
+                              '_end')

+ 2 - 2
misago/markdown/factory.py

@@ -4,7 +4,7 @@ from HTMLParser import HTMLParser
 from django.conf import settings
 from django.utils.importlib import import_module
 from django.utils.translation import ugettext_lazy as _
-from misago.utils import get_random_string
+from misago.utils.strings import random_string
 
 class ClearHTMLParser(HTMLParser):
     def __init__(self):
@@ -87,7 +87,7 @@ def post_markdown(request, text):
                            extensions=['nl2br', 'fenced_code'])
 
     remove_unsupported(md)
-    md.mi_token = get_random_string(16)
+    md.mi_token = random_string(16)
     for extension in settings.MARKDOWN_EXTENSIONS:
         module = '.'.join(extension.split('.')[:-1])
         extension = extension.split('.')[-1]

+ 0 - 0
misago/messages/__init__.py → misago/messages.py


+ 0 - 7
misago/messages/context_processors.py

@@ -1,7 +0,0 @@
-def messages(request):
-    try:
-        return {
-            'messages' : request.messages.messages,
-        }
-    except AttributeError:
-        return {}

+ 0 - 0
misago/readstracker/__init__.py → misago/middleware/__init__.py


+ 3 - 2
misago/acl/middleware.py → misago/middleware/acl.py

@@ -1,13 +1,14 @@
-from misago.acl.builder import get_acl
+from misago.acl.builder import acl
 
 class ACLMiddleware(object):
     def process_request(self, request):
-        request.acl = get_acl(request, request.user)
+        request.acl = acl(request, request.user)
         
         if (request.user.is_authenticated() and
             (request.acl.team or request.user.is_god()) != request.user.is_team):
             request.user.is_team = (request.acl.team or request.user.is_god())
             request.user.save(force_update=True)
+            
         if request.session.team != request.user.is_team:
             request.session.team = request.user.is_team
             request.session.save()

+ 3 - 2
misago/banning/middleware.py → misago/middleware/banning.py

@@ -1,15 +1,16 @@
-from misago.banning.models import BanCache
-from misago.users.models import Guest
+from misago.models import BanCache, Guest
 
 class BanningMiddleware(object):
     def process_request(self, request):
         if request.heartbeat or request.user.is_crawler():
             return None
+            
         try:
             request.ban = request.session['ban']
         except KeyError:
             request.ban = BanCache()
             request.session['ban'] = request.ban
+
         if not request.firewall.admin:
             request.ban.check_for_updates(request)
             # Make sure banned session is downgraded to guest level

+ 31 - 0
misago/middleware/bruteforce.py

@@ -0,0 +1,31 @@
+from datetime import timedelta
+from django.utils import timezone
+from misago.models import SignInAttempt
+
+class JamCache(object):
+    def __init__(self):
+        self.jammed = False
+        self.expires = timezone.now()
+    
+    def check_for_updates(self, request):
+        if self.expires < timezone.now():
+            self.jammed = SignInAttempt.objects.is_jammed(request.settings, request.session.get_ip(request))
+            self.expires = timezone.now() + timedelta(minutes=request.settings['jams_lifetime'])
+            return True
+        return False
+
+    def is_jammed(self):
+        return self.jammed
+
+
+class JamMiddleware(object):
+    def process_request(self, request):
+        if request.user.is_crawler():
+            return None
+        try:
+            request.jam = request.session['jam']
+        except KeyError:
+            request.jam = JamCache()
+            request.session['jam'] = request.jam
+        if not request.firewall.admin:
+            request.jam.check_for_updates(request)

+ 3 - 3
misago/cookie_jar/middleware.py → misago/middleware/cookiejar.py

@@ -1,12 +1,12 @@
-from misago.cookie_jar.cookie_jar import CookieJar
+from misago.cookiejar import CookieJar
 
 class CookieJarMiddleware(object):
     def process_request(self, request):
-        request.cookie_jar = CookieJar()
+        request.cookiejar = CookieJar()
 
     def process_response(self, request, response):
         try:
-            request.cookie_jar.flush(response)
+            request.cookiejar.flush(response)
         except AttributeError:
             pass
         return response

+ 2 - 2
misago/crawlers/middleware.py → misago/middleware/crawlers.py

@@ -1,5 +1,5 @@
-from crawler import Crawler
-from misago.users import models
+from misago.crawlers import Crawler
+from misago import models
 
 class DetectCrawlerMiddleware(object):
     def process_request(self, request):

+ 23 - 0
misago/middleware/csrf.py

@@ -0,0 +1,23 @@
+from misago.utils.strings import random_string
+
+class CSRFProtection(object):
+    def __init__(self, csrf_token):
+        self.csrf_id = '_csrf_token'
+        self.csrf_token = csrf_token
+        
+    def request_secure(self, request):
+        return request.method == 'POST' and request.POST.get(self.csrf_id) == self.csrf_token
+
+
+class CSRFMiddleware(object):
+    def process_request(self, request):
+        if request.user.is_crawler():
+            return None
+
+        if 'csrf_token' in request.session:
+            csrf_token = request.session['csrf_token']
+        else:
+            csrf_token = random_string(16);
+            request.session['csrf_token'] = csrf_token
+        
+        request.csrf = CSRFProtection(csrf_token)

+ 2 - 2
misago/firewalls/middleware.py → misago/middleware/firewalls.py

@@ -1,6 +1,6 @@
 from django.conf import settings
-from misago.firewalls.firewalls import *
-from misago.themes.theme import Theme
+from misago.firewalls import *
+from misago.theme import Theme
 
 class FirewallMiddleware(object):
     firewall_admin = FirewallAdmin()

+ 0 - 0
misago/heartbeat/middleware.py → misago/middleware/heartbeat.py


+ 0 - 0
misago/messages/middleware.py → misago/middleware/messages.py


+ 1 - 1
misago/monitor/middleware.py → misago/middleware/monitor.py

@@ -1,4 +1,4 @@
-from misago.monitor.monitor import Monitor
+from misago.monitor import Monitor
 
 class MonitorMiddleware(object):
     def process_request(self, request):

+ 13 - 0
misago/middleware/privatethreads.py

@@ -0,0 +1,13 @@
+from misago.models import Forum, Thread
+from misago.readstrackers import ThreadsTracker
+
+class PrivateThreadsMiddleware(object):
+    def process_request(self, request):
+        if (request.user.is_authenticated() and
+                request.acl.private_threads.can_participate() and
+                request.user.sync_pds):
+            forum = Forum.objects.special_model('private_threads')
+            tracker = ThreadsTracker(request, forum)
+            unread_pds = tracker.unread_count(forum.thread_set.filter(participants__id=request.user.pk))
+            request.user.sync_unread_pds(unread_pds)
+            request.user.save(force_update=True)

+ 3 - 3
misago/sessions/middleware.py → misago/middleware/session.py

@@ -1,15 +1,15 @@
 from django.utils import timezone
-from misago.sessions.sessions import SessionCrawler, SessionHuman
+from misago.sessions import CrawlerSession, HumanSession
 
 class SessionMiddleware(object):
     def process_request(self, request):
         try:
             if request.user.is_crawler():
                 # Crawler Session
-                request.session = SessionCrawler(request)
+                request.session = CrawlerSession(request)
         except AttributeError:
             # Human Session
-            request.session = SessionHuman(request)
+            request.session = HumanSession(request)
             request.user = request.session.get_user()
 
             if request.user.is_authenticated():

+ 5 - 0
misago/middleware/settings.py

@@ -0,0 +1,5 @@
+from misago.dbsettings import DBSettings
+
+class SettingsMiddleware(object):
+    def process_request(self, request):
+        request.settings = DBSettings()

+ 0 - 0
misago/stopwatch/middleware.py → misago/middleware/stopwatch.py


+ 2 - 2
misago/themes/middleware.py → misago/middleware/theme.py

@@ -1,7 +1,7 @@
 from django.conf import settings
 from django.core.cache import cache
-from misago.themes.theme import Theme
-from misago.themes.models import ThemeAdjustment
+from misago.theme import Theme
+from misago.models import ThemeAdjustment
 
 class ThemeMiddleware(object):
     def process_request(self, request):

+ 0 - 0
misago/users/middleware.py → misago/middleware/user.py


+ 951 - 0
misago/migrations/0001_initial.py

@@ -0,0 +1,951 @@
+# -*- coding: utf-8 -*-
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        # Adding model 'Alert'
+        db.create_table(u'misago_alert', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.User'])),
+            ('date', self.gf('django.db.models.fields.DateTimeField')()),
+            ('message', self.gf('django.db.models.fields.TextField')()),
+            ('variables', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+        ))
+        db.send_create_signal('misago', ['Alert'])
+
+        # Adding model 'Ban'
+        db.create_table(u'misago_ban', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('test', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('ban', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('reason_user', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('reason_admin', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('expires', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
+        ))
+        db.send_create_signal('misago', ['Ban'])
+
+        # Adding model 'Change'
+        db.create_table(u'misago_change', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Forum'])),
+            ('thread', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Thread'])),
+            ('post', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Post'])),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.User'], null=True, on_delete=models.SET_NULL, blank=True)),
+            ('user_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('user_slug', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('date', self.gf('django.db.models.fields.DateTimeField')()),
+            ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
+            ('agent', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('reason', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('thread_name_new', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('thread_name_old', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('post_content', self.gf('django.db.models.fields.TextField')()),
+            ('size', self.gf('django.db.models.fields.IntegerField')(default=0)),
+            ('change', self.gf('django.db.models.fields.IntegerField')(default=0)),
+        ))
+        db.send_create_signal('misago', ['Change'])
+
+        # Adding model 'Checkpoint'
+        db.create_table(u'misago_checkpoint', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Forum'])),
+            ('thread', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Thread'])),
+            ('post', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Post'])),
+            ('action', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.User'], null=True, on_delete=models.SET_NULL, blank=True)),
+            ('user_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('user_slug', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('target_user', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['misago.User'])),
+            ('target_user_name', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('target_user_slug', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('date', self.gf('django.db.models.fields.DateTimeField')()),
+            ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
+            ('agent', self.gf('django.db.models.fields.CharField')(max_length=255)),
+        ))
+        db.send_create_signal('misago', ['Checkpoint'])
+
+        # Adding model 'Fixture'
+        db.create_table(u'misago_fixture', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+        ))
+        db.send_create_signal('misago', ['Fixture'])
+
+        # Adding model 'Forum'
+        db.create_table(u'misago_forum', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('parent', self.gf('mptt.fields.TreeForeignKey')(blank=True, related_name='children', null=True, to=orm['misago.Forum'])),
+            ('type', self.gf('django.db.models.fields.CharField')(max_length=12)),
+            ('special', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('slug', self.gf('django.db.models.fields.SlugField')(max_length=255)),
+            ('description', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('description_preparsed', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('threads', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('threads_delta', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('posts', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('posts_delta', self.gf('django.db.models.fields.IntegerField')(default=0)),
+            ('redirects', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('redirects_delta', self.gf('django.db.models.fields.IntegerField')(default=0)),
+            ('last_thread', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['misago.Thread'])),
+            ('last_thread_name', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('last_thread_slug', self.gf('django.db.models.fields.SlugField')(max_length=255, null=True, blank=True)),
+            ('last_thread_date', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
+            ('last_poster', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['misago.User'])),
+            ('last_poster_name', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('last_poster_slug', self.gf('django.db.models.fields.SlugField')(max_length=255, null=True, blank=True)),
+            ('last_poster_style', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('prune_start', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('prune_last', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('redirect', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('attrs', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('show_details', self.gf('django.db.models.fields.BooleanField')(default=True)),
+            ('style', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('closed', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('lft', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
+            ('rght', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
+            ('tree_id', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
+            ('level', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
+        ))
+        db.send_create_signal('misago', ['Forum'])
+
+        # Adding model 'ForumRead'
+        db.create_table(u'misago_forumread', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.User'])),
+            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Forum'])),
+            ('updated', self.gf('django.db.models.fields.DateTimeField')()),
+            ('cleared', self.gf('django.db.models.fields.DateTimeField')()),
+        ))
+        db.send_create_signal('misago', ['ForumRead'])
+
+        # Adding model 'ForumRole'
+        db.create_table(u'misago_forumrole', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('_permissions', self.gf('django.db.models.fields.TextField')(null=True, db_column='permissions', blank=True)),
+        ))
+        db.send_create_signal('misago', ['ForumRole'])
+
+        # Adding model 'Karma'
+        db.create_table(u'misago_karma', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Forum'])),
+            ('thread', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Thread'])),
+            ('post', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Post'])),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.User'], null=True, on_delete=models.SET_NULL, blank=True)),
+            ('user_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('user_slug', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('date', self.gf('django.db.models.fields.DateTimeField')()),
+            ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
+            ('agent', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('score', self.gf('django.db.models.fields.IntegerField')(default=0)),
+        ))
+        db.send_create_signal('misago', ['Karma'])
+
+        # Adding model 'MonitorItem'
+        db.create_table(u'misago_monitoritem', (
+            ('id', self.gf('django.db.models.fields.CharField')(max_length=255, primary_key=True)),
+            ('value', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('updated', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
+        ))
+        db.send_create_signal('misago', ['MonitorItem'])
+
+        # Adding model 'Newsletter'
+        db.create_table(u'misago_newsletter', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('token', self.gf('django.db.models.fields.CharField')(max_length=32)),
+            ('step_size', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('progress', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('content_html', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('content_plain', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('ignore_subscriptions', self.gf('django.db.models.fields.BooleanField')(default=False)),
+        ))
+        db.send_create_signal('misago', ['Newsletter'])
+
+        # Adding M2M table for field ranks on 'Newsletter'
+        db.create_table(u'misago_newsletter_ranks', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('newsletter', models.ForeignKey(orm['misago.newsletter'], null=False)),
+            ('rank', models.ForeignKey(orm['misago.rank'], null=False))
+        ))
+        db.create_unique(u'misago_newsletter_ranks', ['newsletter_id', 'rank_id'])
+
+        # Adding model 'Post'
+        db.create_table(u'misago_post', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Forum'])),
+            ('thread', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Thread'])),
+            ('merge', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.User'], null=True, on_delete=models.SET_NULL, blank=True)),
+            ('user_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
+            ('agent', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('post', self.gf('django.db.models.fields.TextField')()),
+            ('post_preparsed', self.gf('django.db.models.fields.TextField')()),
+            ('upvotes', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('downvotes', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('checkpoints', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('date', self.gf('django.db.models.fields.DateTimeField')()),
+            ('edits', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('edit_date', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
+            ('edit_reason', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('edit_user', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['misago.User'])),
+            ('edit_user_name', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('edit_user_slug', self.gf('django.db.models.fields.SlugField')(max_length=255, null=True, blank=True)),
+            ('reported', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('moderated', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('deleted', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('protected', self.gf('django.db.models.fields.BooleanField')(default=False)),
+        ))
+        db.send_create_signal('misago', ['Post'])
+
+        # Adding M2M table for field mentions on 'Post'
+        db.create_table(u'misago_post_mentions', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('post', models.ForeignKey(orm['misago.post'], null=False)),
+            ('user', models.ForeignKey(orm['misago.user'], null=False))
+        ))
+        db.create_unique(u'misago_post_mentions', ['post_id', 'user_id'])
+
+        # Adding model 'PruningPolicy'
+        db.create_table(u'misago_pruningpolicy', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('email', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('posts', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('registered', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('last_visit', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+        ))
+        db.send_create_signal('misago', ['PruningPolicy'])
+
+        # Adding model 'Rank'
+        db.create_table(u'misago_rank', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('slug', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('description', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('style', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('title', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('special', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('as_tab', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('on_index', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('order', self.gf('django.db.models.fields.IntegerField')(default=0)),
+            ('criteria', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+        ))
+        db.send_create_signal('misago', ['Rank'])
+
+        # Adding model 'Role'
+        db.create_table(u'misago_role', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('_special', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, db_column='special', blank=True)),
+            ('protected', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('_permissions', self.gf('django.db.models.fields.TextField')(null=True, db_column='permissions', blank=True)),
+        ))
+        db.send_create_signal('misago', ['Role'])
+
+        # Adding model 'Session'
+        db.create_table(u'misago_session', (
+            ('id', self.gf('django.db.models.fields.CharField')(max_length=42, primary_key=True)),
+            ('data', self.gf('django.db.models.fields.TextField')(db_column='session_data')),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='sessions', null=True, on_delete=models.SET_NULL, to=orm['misago.User'])),
+            ('crawler', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
+            ('agent', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('start', self.gf('django.db.models.fields.DateTimeField')()),
+            ('last', self.gf('django.db.models.fields.DateTimeField')()),
+            ('team', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('rank', self.gf('django.db.models.fields.related.ForeignKey')(related_name='sessions', null=True, on_delete=models.SET_NULL, to=orm['misago.Rank'])),
+            ('admin', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('matched', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('hidden', self.gf('django.db.models.fields.BooleanField')(default=False)),
+        ))
+        db.send_create_signal('misago', ['Session'])
+
+        # Adding model 'Setting'
+        db.create_table(u'misago_setting', (
+            ('setting', self.gf('django.db.models.fields.CharField')(max_length=255, primary_key=True)),
+            ('group', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.SettingsGroup'], to_field='key')),
+            ('value', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('value_default', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('normalize_to', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('field', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('extra', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('position', self.gf('django.db.models.fields.IntegerField')(default=0)),
+            ('separator', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('description', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+        ))
+        db.send_create_signal('misago', ['Setting'])
+
+        # Adding model 'SettingsGroup'
+        db.create_table(u'misago_settingsgroup', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('key', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('description', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+        ))
+        db.send_create_signal('misago', ['SettingsGroup'])
+
+        # Adding model 'SignInAttempt'
+        db.create_table(u'misago_signinattempt', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
+            ('date', self.gf('django.db.models.fields.DateTimeField')()),
+        ))
+        db.send_create_signal('misago', ['SignInAttempt'])
+
+        # Adding model 'ThemeAdjustment'
+        db.create_table(u'misago_themeadjustment', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('theme', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)),
+            ('useragents', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+        ))
+        db.send_create_signal('misago', ['ThemeAdjustment'])
+
+        # Adding model 'Thread'
+        db.create_table(u'misago_thread', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Forum'])),
+            ('weight', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('slug', self.gf('django.db.models.fields.SlugField')(max_length=255)),
+            ('replies', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('replies_reported', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('replies_moderated', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('replies_deleted', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('merges', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('score', self.gf('django.db.models.fields.PositiveIntegerField')(default=30)),
+            ('upvotes', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('downvotes', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('start', self.gf('django.db.models.fields.DateTimeField')()),
+            ('start_post', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['misago.Post'])),
+            ('start_poster', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.User'], null=True, on_delete=models.SET_NULL, blank=True)),
+            ('start_poster_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('start_poster_slug', self.gf('django.db.models.fields.SlugField')(max_length=255)),
+            ('start_poster_style', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('last', self.gf('django.db.models.fields.DateTimeField')()),
+            ('last_post', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['misago.Post'])),
+            ('last_poster', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['misago.User'])),
+            ('last_poster_name', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('last_poster_slug', self.gf('django.db.models.fields.SlugField')(max_length=255, null=True, blank=True)),
+            ('last_poster_style', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('moderated', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('deleted', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('closed', self.gf('django.db.models.fields.BooleanField')(default=False)),
+        ))
+        db.send_create_signal('misago', ['Thread'])
+
+        # Adding M2M table for field participants on 'Thread'
+        db.create_table(u'misago_thread_participants', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('thread', models.ForeignKey(orm['misago.thread'], null=False)),
+            ('user', models.ForeignKey(orm['misago.user'], null=False))
+        ))
+        db.create_unique(u'misago_thread_participants', ['thread_id', 'user_id'])
+
+        # Adding model 'ThreadRead'
+        db.create_table(u'misago_threadread', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.User'])),
+            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Forum'])),
+            ('thread', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Thread'])),
+            ('updated', self.gf('django.db.models.fields.DateTimeField')()),
+        ))
+        db.send_create_signal('misago', ['ThreadRead'])
+
+        # Adding model 'Token'
+        db.create_table(u'misago_token', (
+            ('id', self.gf('django.db.models.fields.CharField')(max_length=42, primary_key=True)),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='signin_tokens', to=orm['misago.User'])),
+            ('created', self.gf('django.db.models.fields.DateTimeField')()),
+            ('accessed', self.gf('django.db.models.fields.DateTimeField')()),
+        ))
+        db.send_create_signal('misago', ['Token'])
+
+        # Adding model 'User'
+        db.create_table(u'misago_user', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('username', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('username_slug', self.gf('django.db.models.fields.SlugField')(unique=True, max_length=255)),
+            ('email', self.gf('django.db.models.fields.EmailField')(max_length=255)),
+            ('email_hash', self.gf('django.db.models.fields.CharField')(unique=True, max_length=32)),
+            ('password', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('password_date', self.gf('django.db.models.fields.DateTimeField')()),
+            ('avatar_type', self.gf('django.db.models.fields.CharField')(max_length=10, null=True, blank=True)),
+            ('avatar_image', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('avatar_original', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('avatar_temp', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('signature', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('signature_preparsed', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('join_date', self.gf('django.db.models.fields.DateTimeField')()),
+            ('join_ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
+            ('join_agent', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('last_date', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
+            ('last_ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39, null=True, blank=True)),
+            ('last_agent', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('hide_activity', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('subscribe_start', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('subscribe_reply', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('receive_newsletters', self.gf('django.db.models.fields.BooleanField')(default=True)),
+            ('threads', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('posts', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('votes', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('karma_given_p', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('karma_given_n', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('karma_p', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('karma_n', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('following', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('followers', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('score', self.gf('django.db.models.fields.IntegerField')(default=0)),
+            ('ranking', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('rank', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Rank'], null=True, on_delete=models.SET_NULL, blank=True)),
+            ('last_sync', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
+            ('title', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('last_post', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
+            ('last_search', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
+            ('alerts', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('alerts_date', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
+            ('allow_pds', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('unread_pds', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('sync_pds', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('activation', self.gf('django.db.models.fields.IntegerField')(default=0)),
+            ('token', self.gf('django.db.models.fields.CharField')(max_length=12, null=True, blank=True)),
+            ('avatar_ban', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('avatar_ban_reason_user', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('avatar_ban_reason_admin', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('signature_ban', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('signature_ban_reason_user', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('signature_ban_reason_admin', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('timezone', self.gf('django.db.models.fields.CharField')(default='utc', max_length=255)),
+            ('is_team', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('acl_key', self.gf('django.db.models.fields.CharField')(max_length=12, null=True, blank=True)),
+        ))
+        db.send_create_signal('misago', ['User'])
+
+        # Adding M2M table for field follows on 'User'
+        db.create_table(u'misago_user_follows', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('from_user', models.ForeignKey(orm['misago.user'], null=False)),
+            ('to_user', models.ForeignKey(orm['misago.user'], null=False))
+        ))
+        db.create_unique(u'misago_user_follows', ['from_user_id', 'to_user_id'])
+
+        # Adding M2M table for field ignores on 'User'
+        db.create_table(u'misago_user_ignores', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('from_user', models.ForeignKey(orm['misago.user'], null=False)),
+            ('to_user', models.ForeignKey(orm['misago.user'], null=False))
+        ))
+        db.create_unique(u'misago_user_ignores', ['from_user_id', 'to_user_id'])
+
+        # Adding M2M table for field roles on 'User'
+        db.create_table(u'misago_user_roles', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('user', models.ForeignKey(orm['misago.user'], null=False)),
+            ('role', models.ForeignKey(orm['misago.role'], null=False))
+        ))
+        db.create_unique(u'misago_user_roles', ['user_id', 'role_id'])
+
+        # Adding model 'UsernameChange'
+        db.create_table(u'misago_usernamechange', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='namechanges', to=orm['misago.User'])),
+            ('date', self.gf('django.db.models.fields.DateTimeField')()),
+            ('old_username', self.gf('django.db.models.fields.CharField')(max_length=255)),
+        ))
+        db.send_create_signal('misago', ['UsernameChange'])
+
+        # Adding model 'WatchedThread'
+        db.create_table(u'misago_watchedthread', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.User'])),
+            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Forum'])),
+            ('thread', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['misago.Thread'])),
+            ('last_read', self.gf('django.db.models.fields.DateTimeField')()),
+            ('email', self.gf('django.db.models.fields.BooleanField')(default=False)),
+        ))
+        db.send_create_signal('misago', ['WatchedThread'])
+
+
+    def backwards(self, orm):
+        # Deleting model 'Alert'
+        db.delete_table(u'misago_alert')
+
+        # Deleting model 'Ban'
+        db.delete_table(u'misago_ban')
+
+        # Deleting model 'Change'
+        db.delete_table(u'misago_change')
+
+        # Deleting model 'Checkpoint'
+        db.delete_table(u'misago_checkpoint')
+
+        # Deleting model 'Fixture'
+        db.delete_table(u'misago_fixture')
+
+        # Deleting model 'Forum'
+        db.delete_table(u'misago_forum')
+
+        # Deleting model 'ForumRead'
+        db.delete_table(u'misago_forumread')
+
+        # Deleting model 'ForumRole'
+        db.delete_table(u'misago_forumrole')
+
+        # Deleting model 'Karma'
+        db.delete_table(u'misago_karma')
+
+        # Deleting model 'MonitorItem'
+        db.delete_table(u'misago_monitoritem')
+
+        # Deleting model 'Newsletter'
+        db.delete_table(u'misago_newsletter')
+
+        # Removing M2M table for field ranks on 'Newsletter'
+        db.delete_table('misago_newsletter_ranks')
+
+        # Deleting model 'Post'
+        db.delete_table(u'misago_post')
+
+        # Removing M2M table for field mentions on 'Post'
+        db.delete_table('misago_post_mentions')
+
+        # Deleting model 'PruningPolicy'
+        db.delete_table(u'misago_pruningpolicy')
+
+        # Deleting model 'Rank'
+        db.delete_table(u'misago_rank')
+
+        # Deleting model 'Role'
+        db.delete_table(u'misago_role')
+
+        # Deleting model 'Session'
+        db.delete_table(u'misago_session')
+
+        # Deleting model 'Setting'
+        db.delete_table(u'misago_setting')
+
+        # Deleting model 'SettingsGroup'
+        db.delete_table(u'misago_settingsgroup')
+
+        # Deleting model 'SignInAttempt'
+        db.delete_table(u'misago_signinattempt')
+
+        # Deleting model 'ThemeAdjustment'
+        db.delete_table(u'misago_themeadjustment')
+
+        # Deleting model 'Thread'
+        db.delete_table(u'misago_thread')
+
+        # Removing M2M table for field participants on 'Thread'
+        db.delete_table('misago_thread_participants')
+
+        # Deleting model 'ThreadRead'
+        db.delete_table(u'misago_threadread')
+
+        # Deleting model 'Token'
+        db.delete_table(u'misago_token')
+
+        # Deleting model 'User'
+        db.delete_table(u'misago_user')
+
+        # Removing M2M table for field follows on 'User'
+        db.delete_table('misago_user_follows')
+
+        # Removing M2M table for field ignores on 'User'
+        db.delete_table('misago_user_ignores')
+
+        # Removing M2M table for field roles on 'User'
+        db.delete_table('misago_user_roles')
+
+        # Deleting model 'UsernameChange'
+        db.delete_table(u'misago_usernamechange')
+
+        # Deleting model 'WatchedThread'
+        db.delete_table(u'misago_watchedthread')
+
+
+    models = {
+        'misago.alert': {
+            'Meta': {'object_name': 'Alert'},
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'message': ('django.db.models.fields.TextField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']"}),
+            'variables': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'misago.ban': {
+            'Meta': {'object_name': 'Ban'},
+            'ban': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'expires': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'test': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.change': {
+            'Meta': {'object_name': 'Change'},
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'change': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'post': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Post']"}),
+            'post_content': ('django.db.models.fields.TextField', [], {}),
+            'reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'size': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'thread_name_new': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'thread_name_old': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.checkpoint': {
+            'Meta': {'object_name': 'Checkpoint'},
+            'action': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'post': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Post']"}),
+            'target_user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'target_user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'target_user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.fixture': {
+            'Meta': {'object_name': 'Fixture'},
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.forum': {
+            'Meta': {'object_name': 'Forum'},
+            'attrs': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'description_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_thread': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Thread']"}),
+            'last_thread_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_thread_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_thread_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'parent': ('mptt.fields.TreeForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['misago.Forum']"}),
+            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'posts_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'prune_last': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'prune_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'redirect': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'redirects': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'redirects_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'show_details': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'special': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'threads_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'type': ('django.db.models.fields.CharField', [], {'max_length': '12'})
+        },
+        'misago.forumread': {
+            'Meta': {'object_name': 'ForumRead'},
+            'cleared': ('django.db.models.fields.DateTimeField', [], {}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']"})
+        },
+        'misago.forumrole': {
+            'Meta': {'object_name': 'ForumRole'},
+            '_permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'permissions'", 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.karma': {
+            'Meta': {'object_name': 'Karma'},
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'post': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Post']"}),
+            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.monitoritem': {
+            'Meta': {'object_name': 'MonitorItem'},
+            'id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'primary_key': 'True'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'misago.newsletter': {
+            'Meta': {'object_name': 'Newsletter'},
+            'content_html': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'content_plain': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ignore_subscriptions': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'progress': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'ranks': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['misago.Rank']", 'symmetrical': 'False'}),
+            'step_size': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'token': ('django.db.models.fields.CharField', [], {'max_length': '32'})
+        },
+        'misago.post': {
+            'Meta': {'object_name': 'Post'},
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'checkpoints': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'edit_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'edit_reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'edit_user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'edit_user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'edit_user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'edits': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'mentions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'mention_set'", 'symmetrical': 'False', 'to': "orm['misago.User']"}),
+            'merge': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'post': ('django.db.models.fields.TextField', [], {}),
+            'post_preparsed': ('django.db.models.fields.TextField', [], {}),
+            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'reported': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.pruningpolicy': {
+            'Meta': {'object_name': 'PruningPolicy'},
+            'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_visit': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'registered': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.rank': {
+            'Meta': {'object_name': 'Rank'},
+            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
+        },
+        'misago.role': {
+            'Meta': {'object_name': 'Role'},
+            '_permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'permissions'", 'blank': 'True'}),
+            '_special': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_column': "'special'", 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
+        },
+        'misago.session': {
+            'Meta': {'object_name': 'Session'},
+            'admin': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'crawler': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'data': ('django.db.models.fields.TextField', [], {'db_column': "'session_data'"}),
+            'hidden': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'id': ('django.db.models.fields.CharField', [], {'max_length': '42', 'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'last': ('django.db.models.fields.DateTimeField', [], {}),
+            'matched': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'rank': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sessions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Rank']"}),
+            'start': ('django.db.models.fields.DateTimeField', [], {}),
+            'team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sessions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"})
+        },
+        'misago.setting': {
+            'Meta': {'object_name': 'Setting'},
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'extra': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'group': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.SettingsGroup']", 'to_field': "'key'"}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'normalize_to': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'position': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'separator': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'setting': ('django.db.models.fields.CharField', [], {'max_length': '255', 'primary_key': 'True'}),
+            'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'value_default': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'misago.settingsgroup': {
+            'Meta': {'object_name': 'SettingsGroup'},
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.signinattempt': {
+            'Meta': {'object_name': 'SignInAttempt'},
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'})
+        },
+        'misago.themeadjustment': {
+            'Meta': {'object_name': 'ThemeAdjustment'},
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'theme': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
+            'useragents': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'misago.thread': {
+            'Meta': {'object_name': 'Thread'},
+            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last': ('django.db.models.fields.DateTimeField', [], {}),
+            'last_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Post']"}),
+            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'merges': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'participants': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'+'", 'symmetrical': 'False', 'to': "orm['misago.User']"}),
+            'replies': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'replies_deleted': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'replies_moderated': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'replies_reported': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'score': ('django.db.models.fields.PositiveIntegerField', [], {'default': '30'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'start': ('django.db.models.fields.DateTimeField', [], {}),
+            'start_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Post']"}),
+            'start_poster': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'start_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'start_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'start_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'weight': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.threadread': {
+            'Meta': {'object_name': 'ThreadRead'},
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']"})
+        },
+        'misago.token': {
+            'Meta': {'object_name': 'Token'},
+            'accessed': ('django.db.models.fields.DateTimeField', [], {}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.CharField', [], {'max_length': '42', 'primary_key': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'signin_tokens'", 'to': "orm['misago.User']"})
+        },
+        'misago.user': {
+            'Meta': {'object_name': 'User'},
+            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
+            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'allow_pds': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
+            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
+            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': "orm['misago.User']"}),
+            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': "orm['misago.User']"}),
+            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
+            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
+            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
+            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['misago.Role']", 'symmetrical': 'False'}),
+            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'sync_pds': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
+            'unread_pds': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
+            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.usernamechange': {
+            'Meta': {'object_name': 'UsernameChange'},
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'old_username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'namechanges'", 'to': "orm['misago.User']"})
+        },
+        'misago.watchedthread': {
+            'Meta': {'object_name': 'WatchedThread'},
+            'email': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_read': ('django.db.models.fields.DateTimeField', [], {}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']"})
+        }
+    }
+
+    complete_apps = ['misago']

+ 0 - 0
misago/readstracker/management/__init__.py → misago/migrations/__init__.py


+ 26 - 0
misago/models/__init__.py

@@ -0,0 +1,26 @@
+from misago.models.alertmodel import Alert
+from misago.models.banmodel import Ban, BanCache
+from misago.models.changemodel import Change
+from misago.models.checkpointmodel import Checkpoint
+from misago.models.fixturemodel import Fixture
+from misago.models.forummodel import Forum
+from misago.models.forumreadmodel import ForumRead
+from misago.models.forumrolemodel import ForumRole
+from misago.models.karmamodel import Karma
+from misago.models.monitoritemmodel import MonitorItem
+from misago.models.newslettermodel import Newsletter
+from misago.models.postmodel import Post
+from misago.models.pruningpolicymodel import PruningPolicy
+from misago.models.rankmodel import Rank
+from misago.models.rolemodel import Role
+from misago.models.sessionmodel import Session
+from misago.models.settingmodel import Setting
+from misago.models.settingsgroupmodel import SettingsGroup
+from misago.models.signinattemptmodel import SignInAttempt
+from misago.models.themeadjustmentmodel import ThemeAdjustment
+from misago.models.threadmodel import Thread
+from misago.models.threadreadmodel import ThreadRead
+from misago.models.tokenmodel import Token
+from misago.models.usermodel import User, Guest, Crawler
+from misago.models.usernamechangemodel import UsernameChange
+from misago.models.watchedthreadmodel import WatchedThread

+ 8 - 5
misago/alerts/models.py → misago/models/alertmodel.py

@@ -7,11 +7,14 @@ except ImportError:
     import pickle
 
 class Alert(models.Model):
-    user = models.ForeignKey('users.User')
+    user = models.ForeignKey('User')
     date = models.DateTimeField()
     message = models.TextField()
     variables = models.TextField(null=True, blank=True)
 
+    class Meta:
+        app_label = 'misago'
+
     def vars(self):
         try:
             return pickle.loads(base64.decodestring(self.variables))
@@ -49,13 +52,13 @@ class Alert(models.Model):
         from django.core.urlresolvers import reverse
         return self.url(var, user.username, reverse('user', kwargs={'user': user.pk, 'username': user.username_slug}))
 
-    def thread(self, var, thread):
+    def thread(self, var, thread_type, thread):
         from django.core.urlresolvers import reverse
-        return self.url(var, thread.name, reverse('thread', kwargs={'thread': thread.pk, 'slug': thread.slug}))
+        return self.url(var, thread.name, reverse(thread_type, kwargs={'thread': thread.pk, 'slug': thread.slug}))
 
-    def post(self, var, thread, post):
+    def post(self, var, thread_type, thread, post):
         from django.core.urlresolvers import reverse
-        return self.url(var, thread.name, reverse('thread_find', kwargs={'thread': thread.pk, 'slug': thread.slug, 'post': post.pk}))
+        return self.url(var, thread.name, reverse('%s_find' % thread_type, kwargs={'thread': thread.pk, 'slug': thread.slug, 'post': post.pk}))
 
     def save_all(self, *args, **kwargs):
         self.save(force_insert=True)

+ 90 - 0
misago/models/banmodel.py

@@ -0,0 +1,90 @@
+import re
+from django.db import models
+from django.db.models import Q
+from django.utils import timezone
+
+BAN_NAME_EMAIL = 0
+BAN_NAME = 1
+BAN_EMAIL = 2
+BAN_IP = 3
+
+
+class BansManager(models.Manager):
+    def check_ban(self, ip=False, username=False, email=False):
+        bans_model = Ban.objects.filter(Q(expires=None) | Q(expires__gt=timezone.now()))
+        if not (ip and username and email):
+            if ip:
+                bans_model.filter(test=BAN_IP)
+            if username:
+                bans_model.filter(test=BAN_NAME_EMAIL)
+                bans_model.filter(test=BAN_NAME)
+            if email:
+                bans_model.filter(test=BAN_NAME_EMAIL)
+                bans_model.filter(test=BAN_EMAIL)
+        for ban in bans_model.order_by('-expires').iterator():
+            if (
+                # Check user name
+                ((username and (ban.test == BAN_NAME_EMAIL or ban.test == BAN_NAME))
+                and re.search('^' + re.escape(ban.ban).replace('\*', '(.*?)') + '$', username, flags=re.IGNORECASE))
+                or # Check user email
+                ((email and (ban.test == BAN_NAME_EMAIL or ban.test == BAN_EMAIL))
+                and re.search('^' + re.escape(ban.ban).replace('\*', '(.*?)') + '$', email, flags=re.IGNORECASE))
+                or # Check IP address
+                (ip and ban.test == BAN_IP
+                and re.search('^' + re.escape(ban.ban).replace('\*', '(.*?)') + '$', ip, flags=re.IGNORECASE))):
+                    return ban
+        return False
+
+
+class Ban(models.Model):
+    test = models.PositiveIntegerField(default=BAN_NAME_EMAIL)
+    ban = models.CharField(max_length=255)
+    reason_user = models.TextField(null=True, blank=True)
+    reason_admin = models.TextField(null=True, blank=True)
+    expires = models.DateTimeField(null=True, blank=True)
+
+    objects = BansManager()
+
+    class Meta:
+        app_label = 'misago'
+
+
+class BanCache(object):
+    def __init__(self):
+        self.banned = False
+        self.test = None
+        self.expires = None
+        self.reason_user = None
+        self.version = 0
+
+    def check_for_updates(self, request):
+        if (self.version < request.monitor['bans_version']
+            or (self.expires != None and self.expires < timezone.now())):
+            self.version = request.monitor['bans_version']
+
+            # Check Ban
+            if request.user.is_authenticated():
+                ban = Ban.objects.check_ban(
+                                ip=request.session.get_ip(request),
+                                username=request.user.username,
+                                email=request.user.email
+                                )
+            else:
+                ban = Ban.objects.check_ban(ip=request.session.get_ip(request))
+
+            # Update ban cache
+            if ban:
+                self.banned = True
+                self.reason_user = ban.reason_user
+                self.expires = ban.expires
+                self.test = ban.test
+            else:
+                self.banned = False
+                self.reason_user = None
+                self.expires = None
+                self.test = None
+            return True
+        return False
+
+    def is_banned(self):
+        return self.banned

+ 61 - 0
misago/models/changemodel.py

@@ -0,0 +1,61 @@
+from django.db import models
+from misago.signals import (merge_post, merge_thread, move_forum_content,
+                            move_post, move_thread, rename_user)
+
+class Change(models.Model):
+    forum = models.ForeignKey('Forum')
+    thread = models.ForeignKey('Thread')
+    post = models.ForeignKey('Post')
+    user = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL)
+    user_name = models.CharField(max_length=255)
+    user_slug = models.CharField(max_length=255)
+    date = models.DateTimeField()
+    ip = models.GenericIPAddressField()
+    agent = models.CharField(max_length=255)
+    reason = models.CharField(max_length=255, null=True, blank=True)
+    thread_name_new = models.CharField(max_length=255, null=True, blank=True)
+    thread_name_old = models.CharField(max_length=255, null=True, blank=True)
+    post_content = models.TextField()
+    size = models.IntegerField(default=0)
+    change = models.IntegerField(default=0)
+
+    class Meta:
+        app_label = 'misago'
+
+
+def rename_user_handler(sender, **kwargs):
+    Change.objects.filter(user=sender).update(
+                                              user_name=sender.username,
+                                              user_slug=sender.username_slug,
+                                              )
+rename_user.connect(rename_user_handler, dispatch_uid="rename_user_changes")
+
+
+def move_forum_content_handler(sender, **kwargs):
+    Change.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_changes")
+
+
+def move_thread_handler(sender, **kwargs):
+    Change.objects.filter(thread=sender).update(forum=kwargs['move_to'])
+
+move_thread.connect(move_thread_handler, dispatch_uid="move_thread_changes")
+
+
+def merge_thread_handler(sender, **kwargs):
+    Change.objects.filter(thread=sender).update(thread=kwargs['new_thread'])
+
+merge_thread.connect(merge_thread_handler, dispatch_uid="merge_threads_changes")
+
+
+def move_posts_handler(sender, **kwargs):
+    Change.objects.filter(post=sender).update(forum=kwargs['move_to'].forum, thread=kwargs['move_to'])
+
+move_post.connect(move_posts_handler, dispatch_uid="move_posts_changes")
+
+
+def merge_posts_handler(sender, **kwargs):
+    Change.objects.filter(post=sender).update(post=kwargs['new_post'])
+
+merge_post.connect(merge_posts_handler, dispatch_uid="merge_posts_changes")

+ 67 - 0
misago/models/checkpointmodel.py

@@ -0,0 +1,67 @@
+from django.db import models
+from misago.signals import (merge_post, merge_thread, move_forum_content,
+                            move_post, move_thread, rename_user)
+
+class Checkpoint(models.Model):
+    forum = models.ForeignKey('Forum')
+    thread = models.ForeignKey('Thread')
+    post = models.ForeignKey('Post')
+    action = models.CharField(max_length=255)
+    user = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL)
+    user_name = models.CharField(max_length=255)
+    user_slug = models.CharField(max_length=255)
+    target_user = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL, related_name='+')
+    target_user_name = models.CharField(max_length=255, null=True, blank=True)
+    target_user_slug = models.CharField(max_length=255, null=True, blank=True)
+    date = models.DateTimeField()
+    ip = models.GenericIPAddressField()
+    agent = models.CharField(max_length=255)
+
+    class Meta:
+        app_label = 'misago'
+
+
+def rename_user_handler(sender, **kwargs):
+    Checkpoint.objects.filter(user=sender).update(
+                                                  user_name=sender.username,
+                                                  user_slug=sender.username_slug,
+                                                  )
+
+rename_user.connect(rename_user_handler, dispatch_uid="rename_user_checkpoints")
+
+
+def move_forum_content_handler(sender, **kwargs):
+    Checkpoint.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_checkpoints")
+
+
+def move_thread_handler(sender, **kwargs):
+    Checkpoint.objects.filter(thread=sender).update(forum=kwargs['move_to'])
+
+move_thread.connect(move_thread_handler, dispatch_uid="move_thread_checkpoints")
+
+
+def merge_thread_handler(sender, **kwargs):
+    Checkpoint.objects.filter(thread=sender).delete()
+
+merge_thread.connect(merge_thread_handler, dispatch_uid="merge_threads_checkpoints")
+
+
+def move_posts_handler(sender, **kwargs):
+    if sender.checkpoints:
+        prev_post = Post.objects.filter(thread=sender.thread_id).filter(merge__lte=sender.merge).exclude(id=sender.pk).order_by('merge', '-id')[:1][0]
+        Checkpoint.objects.filter(post=sender).update(post=prev_post)
+        prev_post.checkpoints = True
+        prev_post.save(force_update=True)
+    sender.checkpoints = False
+
+move_post.connect(move_posts_handler, dispatch_uid="move_posts_checkpoints")
+
+
+def merge_posts_handler(sender, **kwargs):
+    Checkpoint.objects.filter(post=sender).update(post=kwargs['new_post'])
+    if sender.checkpoints:
+        kwargs['new_post'].checkpoints = True
+
+merge_post.connect(merge_posts_handler, dispatch_uid="merge_posts_checkpoints")

+ 7 - 0
misago/models/fixturemodel.py

@@ -0,0 +1,7 @@
+from django.db import models
+
+class Fixture(models.Model):
+    name = models.CharField(max_length=255)
+
+    class Meta:
+        app_label = 'misago'

+ 52 - 23
misago/forums/models.py → misago/models/forummodel.py

@@ -1,21 +1,21 @@
+from mptt.managers import TreeManager
+from mptt.models import MPTTModel, TreeForeignKey
 from django.conf import settings
 from django.core.cache import cache
 from django.db import models
 from django.utils.translation import ugettext_lazy as _
-from mptt.models import MPTTModel, TreeForeignKey
-from misago.forums.signals import move_forum_content, delete_forum_content
-from misago.roles.models import Role
-from misago.users.signals import rename_user
+from misago.signals import delete_forum_content, move_forum_content, rename_user
 
-class ForumManager(models.Manager):
+class ForumManager(TreeManager):
     forums_tree = None
 
-    def token_to_pk(self, token):
+    def special_pk(self, name):
         self.populate_tree()
-        try:
-            return self.forums_tree[token].pk
-        except KeyError:
-            return 0
+        return self.forums_tree[name].pk
+
+    def special_model(self, name):
+        self.populate_tree()
+        return self.forums_tree[name]
 
     def populate_tree(self, force=False):
         if not self.forums_tree:
@@ -24,8 +24,8 @@ class ForumManager(models.Manager):
             self.forums_tree = {}
             for forum in Forum.objects.order_by('lft'):
                 self.forums_tree[forum.pk] = forum
-                if forum.token:
-                    self.forums_tree[forum.token] = forum
+                if forum.special:
+                    self.forums_tree[forum.special] = forum
             cache.set('forums_tree', self.forums_tree)
 
     def forum_parents(self, forum, include_self=False):
@@ -112,11 +112,21 @@ class ForumManager(models.Manager):
             for user in user.ignores.filter(id__in=check_ids).values('id'):
                 ignored_ids.append(user['id'])
 
+    def readable_forums(self, acl, include_special=False):
+        self.populate_tree()
+        readable = []
+        for pk, forum in self.forums_tree.items():
+            if ((include_special or not forum.special) and 
+                    acl.forums.can_browse(pk) and
+                    acl.threads.acl[pk]['can_read_threads']):
+                readable.append(pk)
+        return readable
+
 
 class Forum(MPTTModel):
     parent = TreeForeignKey('self', null=True, blank=True, related_name='children')
     type = models.CharField(max_length=12)
-    token = models.CharField(max_length=255, null=True, blank=True)
+    special = models.CharField(max_length=255, null=True, blank=True)
     name = models.CharField(max_length=255)
     slug = models.SlugField(max_length=255)
     description = models.TextField(null=True, blank=True)
@@ -127,11 +137,11 @@ class Forum(MPTTModel):
     posts_delta = models.IntegerField(default=0)
     redirects = models.PositiveIntegerField(default=0)
     redirects_delta = models.IntegerField(default=0)
-    last_thread = models.ForeignKey('threads.Thread', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
+    last_thread = models.ForeignKey('Thread', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
     last_thread_name = models.CharField(max_length=255, null=True, blank=True)
     last_thread_slug = models.SlugField(max_length=255, null=True, blank=True)
     last_thread_date = models.DateTimeField(null=True, blank=True)
-    last_poster = models.ForeignKey('users.User', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
+    last_poster = models.ForeignKey('User', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
     last_poster_name = models.CharField(max_length=255, null=True, blank=True)
     last_poster_slug = models.SlugField(max_length=255, null=True, blank=True)
     last_poster_style = models.CharField(max_length=255, null=True, blank=True)
@@ -145,8 +155,19 @@ class Forum(MPTTModel):
 
     objects = ForumManager()
 
+    class Meta:
+        app_label = 'misago'
+    
+    def delete(self, *args, **kwargs):
+        delete_forum_content.send(sender=self)
+        super(Forum, self).delete(*args, **kwargs)
+
     def __unicode__(self):
-        if self.token == 'root':
+        if self.special == 'private_threads':
+           return unicode(_('Private Threads'))
+        if self.special == 'reports':
+           return unicode(_('Reports'))
+        if self.special == 'root':
            return unicode(_('Root Category'))
         return unicode(self.name)
 
@@ -159,11 +180,12 @@ class Forum(MPTTModel):
 
     def copy_permissions(self, target):
         if target.pk != self.pk:
+            from misago.models import Role
             for role in Role.objects.all():
-                perms = role.get_permissions()
+                perms = role.permissions
                 try:
                     perms['forums'][self.pk] = perms['forums'][target.pk]
-                    role.set_permissions(perms)
+                    role.permissions = perms
                     role.save(force_update=True)
                 except KeyError:
                     pass
@@ -176,6 +198,16 @@ class Forum(MPTTModel):
             return att in self.attrs.split()
         return False
 
+    def new_last_thread(self, thread):
+        self.last_thread = thread
+        self.last_thread_name = thread.name
+        self.last_thread_slug = thread.slug
+        self.last_thread_date = thread.last
+        self.last_poster = thread.last_poster
+        self.last_poster_name = thread.last_poster_name
+        self.last_poster_slug = thread.last_poster_slug
+        self.last_poster_style = thread.last_poster_style
+
     def sync(self):
         self.threads = self.thread_set.filter(moderated=False).filter(deleted=False).count()
         self.posts = self.post_set.filter(moderated=False).count()
@@ -203,10 +235,7 @@ class Forum(MPTTModel):
 
     def prune(self):
         pass
-    
-    def delete(self, *args, **kwargs):
-        delete_forum_content.send(sender=self)
-        super(Forum, self).delete(*args, **kwargs)
+
 
 """
 Signals
@@ -217,4 +246,4 @@ def rename_user_handler(sender, **kwargs):
                                                     last_poster_slug=sender.username_slug,
                                                     )
 
-rename_user.connect(rename_user_handler, dispatch_uid="rename_forums_last_poster")
+rename_user.connect(rename_user_handler, dispatch_uid='rename_forums_last_poster')

+ 29 - 0
misago/models/forumreadmodel.py

@@ -0,0 +1,29 @@
+from datetime import timedelta
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+from misago.signals import move_forum_content
+
+class ForumRead(models.Model):
+    user = models.ForeignKey('User')
+    forum = models.ForeignKey('Forum')
+    updated = models.DateTimeField()
+    cleared = models.DateTimeField()
+    
+    class Meta:
+        app_label = 'misago'
+
+    def get_threads(self):
+        from misago.models import ThreadRead
+        
+        threads = {}
+        for thread in ThreadRead.objects.filter(user=self.user, forum=self.forum, updated__gte=(timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH))):
+            threads[thread.thread_id] = thread
+        return threads
+
+
+def move_forum_content_handler(sender, **kwargs):
+    ForumRead.objects.filter(forum=sender).delete()
+    ForumRead.objects.filter(forum=kwargs['move_to']).delete()
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_reads")

+ 10 - 9
misago/forumroles/models.py → misago/models/forumrolemodel.py

@@ -6,24 +6,24 @@ try:
 except ImportError:
     import pickle
 
-
 class ForumRole(models.Model):
-    """
-    Misago User Role model
-    """
     name = models.CharField(max_length=255)
-    permissions = models.TextField(null=True, blank=True)
+    _permissions = models.TextField(db_column = 'permissions', null=True, blank=True)
     permissions_cache = {}
 
+    class Meta:
+        app_label = 'misago'
+
     def __unicode__(self):
         return unicode(_(self.name))
 
-    def get_permissions(self):
+    @property
+    def permissions(self):
         if self.permissions_cache:
             return self.permissions_cache
 
         try:
-            self.permissions_cache = pickle.loads(base64.decodestring(self.permissions))
+            self.permissions_cache = pickle.loads(base64.decodestring(self._permissions))
         except Exception:
             # ValueError, SuspiciousOperation, unpickling exceptions. If any of
             # these happen, just return an empty dictionary (an empty permissions list).
@@ -31,6 +31,7 @@ class ForumRole(models.Model):
 
         return self.permissions_cache
 
-    def set_permissions(self, permissions):
+    @permissions.setter
+    def permissions(self, permissions):
         self.permissions_cache = permissions
-        self.permissions = base64.encodestring(pickle.dumps(permissions, pickle.HIGHEST_PROTOCOL))
+        self._permissions = base64.encodestring(pickle.dumps(permissions, pickle.HIGHEST_PROTOCOL))

+ 60 - 0
misago/models/karmamodel.py

@@ -0,0 +1,60 @@
+from django.db import models
+from misago.signals import (merge_post, merge_thread, move_forum_content,
+                            move_post, move_thread, rename_user)
+
+class Karma(models.Model):
+    forum = models.ForeignKey('Forum')
+    thread = models.ForeignKey('Thread')
+    post = models.ForeignKey('Post')
+    user = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL)
+    user_name = models.CharField(max_length=255)
+    user_slug = models.CharField(max_length=255)
+    date = models.DateTimeField()
+    ip = models.GenericIPAddressField()
+    agent = models.CharField(max_length=255)
+    score = models.IntegerField(default=0)
+
+    class Meta:
+        app_label = 'misago'
+
+
+def rename_user_handler(sender, **kwargs):
+    Karma.objects.filter(user=sender).update(
+                                             user_name=sender.username,
+                                             user_slug=sender.username_slug,
+                                             )
+
+rename_user.connect(rename_user_handler, dispatch_uid="rename_user_karmas")
+
+
+def move_forum_content_handler(sender, **kwargs):
+    Karma.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_karmas")
+
+
+def move_thread_handler(sender, **kwargs):
+    Karma.objects.filter(thread=sender).update(forum=kwargs['move_to'])
+
+move_thread.connect(move_thread_handler, dispatch_uid="move_thread_karmas")
+
+
+def merge_thread_handler(sender, **kwargs):
+    Karma.objects.filter(thread=sender).update(thread=kwargs['new_thread'])
+
+merge_thread.connect(merge_thread_handler, dispatch_uid="merge_threads_karmas")
+
+
+def move_posts_handler(sender, **kwargs):
+    Karma.objects.filter(post=sender).update(forum=kwargs['move_to'].forum, thread=kwargs['move_to'])
+
+move_post.connect(move_posts_handler, dispatch_uid="move_posts_karmas")
+
+
+def merge_posts_handler(sender, **kwargs):
+    Karma.objects.filter(post=sender).update(post=kwargs['new_post'])
+    kwargs['new_post'].upvotes += self.upvotes
+    kwargs['new_post'].downvotes += self.downvotes
+    kwargs['new_post'].score += self.score
+
+merge_post.connect(merge_posts_handler, dispatch_uid="merge_posts_karmas")

+ 4 - 1
misago/monitor/models.py → misago/models/monitoritemmodel.py

@@ -1,6 +1,9 @@
 from django.db import models
 
-class Item(models.Model):
+class MonitorItem(models.Model):
     id = models.CharField(max_length=255, primary_key=True)
     value = models.TextField(blank=True, null=True)
     updated = models.DateTimeField(blank=True, null=True)
+
+    class Meta:
+        app_label = 'misago'

+ 7 - 4
misago/newsletters/models.py → misago/models/newslettermodel.py

@@ -1,5 +1,5 @@
 from django.db import models
-from misago.utils import get_random_string
+from misago.utils.strings import random_string
 
 class Newsletter(models.Model):
     name = models.CharField(max_length=255)
@@ -9,10 +9,13 @@ class Newsletter(models.Model):
     content_html = models.TextField(null=True, blank=True)
     content_plain = models.TextField(null=True, blank=True)
     ignore_subscriptions = models.BooleanField(default=False)
-    ranks = models.ManyToManyField('ranks.Rank')
+    ranks = models.ManyToManyField('Rank')
+
+    class Meta:
+        app_label = 'misago'
 
     def generate_token(self):
-        self.token = get_random_string(32)
+        self.token = random_string(32)
 
     def parse_name(self, tokens):
         name = self.name
@@ -30,4 +33,4 @@ class Newsletter(models.Model):
         content_plain = self.content_plain
         for key in tokens:
             content_plain = content_plain.replace(key, tokens[key])
-        return content_plain
+        return content_plain

+ 156 - 0
misago/models/postmodel.py

@@ -0,0 +1,156 @@
+from django.db import models
+from django.db.models import F
+from django.utils import timezone
+from django.utils.translation import ugettext_lazy as _
+from misago.signals import (delete_user_content, merge_post, merge_thread,
+                            move_forum_content, move_post, move_thread, rename_user)
+
+class PostManager(models.Manager):
+    def filter_stats(self, start, end):
+        return self.filter(date__gte=start).filter(date__lte=end)
+
+
+class Post(models.Model):
+    forum = models.ForeignKey('Forum')
+    thread = models.ForeignKey('Thread')
+    merge = models.PositiveIntegerField(default=0)
+    user = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL)
+    user_name = models.CharField(max_length=255)
+    ip = models.GenericIPAddressField()
+    agent = models.CharField(max_length=255)
+    post = models.TextField()
+    post_preparsed = models.TextField()
+    upvotes = models.PositiveIntegerField(default=0)
+    downvotes = models.PositiveIntegerField(default=0)
+    mentions = models.ManyToManyField('User', related_name="mention_set")
+    checkpoints = models.BooleanField(default=False)
+    date = models.DateTimeField()
+    edits = models.PositiveIntegerField(default=0)
+    edit_date = models.DateTimeField(null=True, blank=True)
+    edit_reason = models.CharField(max_length=255, null=True, blank=True)
+    edit_user = models.ForeignKey('User', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
+    edit_user_name = models.CharField(max_length=255, null=True, blank=True)
+    edit_user_slug = models.SlugField(max_length=255, null=True, blank=True)
+    reported = models.BooleanField(default=False)
+    moderated = models.BooleanField(default=False)
+    deleted = models.BooleanField(default=False)
+    protected = models.BooleanField(default=False)
+
+    objects = PostManager()
+
+    statistics_name = _('New Posts')
+
+    class Meta:
+        app_label = 'misago'
+
+    def get_date(self):
+        return self.date
+
+    def move_to(self, thread):
+        move_post.send(sender=self, move_to=thread)
+        self.thread = thread
+        self.forum = thread.forum
+        
+    def merge_with(self, post):
+        post.post = '%s\n- - -\n%s' % (post.post, self.post)
+        merge_post.send(sender=self, new_post=post)
+
+    def set_checkpoint(self, request, action, user=None):
+        if request.user.is_authenticated():
+            self.checkpoints = True
+            self.checkpoint_set.create(
+                                       forum=self.forum,
+                                       thread=self.thread,
+                                       post=self,
+                                       action=action,
+                                       user=request.user,
+                                       user_name=request.user.username,
+                                       user_slug=request.user.username_slug,
+                                       date=timezone.now(),
+                                       ip=request.session.get_ip(request),
+                                       agent=request.META.get('HTTP_USER_AGENT'),
+                                       target_user=user,
+                                       target_user_name=(user.username if user else None),
+                                       target_user_slug=(user.username_slug if user else None),
+                                       )
+            
+    def notify_mentioned(self, request, thread_type, users):
+        from misago.acl.builder import acl
+        from misago.acl.exceptions import ACLError403, ACLError404
+        
+        mentioned = self.mentions.all()
+        for slug, user in users.items():
+            if user.pk != request.user.pk and user not in mentioned:
+                self.mentions.add(user)
+                try:                    
+                    acl = acl(request, user)
+                    acl.forums.allow_forum_view(self.forum)
+                    acl.threads.allow_thread_view(user, self.thread)
+                    acl.threads.allow_post_view(user, self.thread, self)
+                    if not user.is_ignoring(request.user):
+                        alert = user.alert(ugettext_lazy("%(username)s has mentioned you in his reply in thread %(thread)s").message)
+                        alert.profile('username', request.user)
+                        alert.post('thread', thread_type, self.thread, self)
+                        alert.save_all()
+                except (ACLError403, ACLError404):
+                    pass
+
+
+def rename_user_handler(sender, **kwargs):
+    Post.objects.filter(user=sender).update(
+                                            user_name=sender.username,
+                                            )
+    Post.objects.filter(edit_user=sender).update(
+                                                 edit_user_name=sender.username,
+                                                 edit_user_slug=sender.username_slug,
+                                                 )
+
+rename_user.connect(rename_user_handler, dispatch_uid="rename_user_posts")
+
+
+def delete_user_content_handler(sender, **kwargs):
+    from misago.models import Thread
+
+    threads = []
+    prev_posts = []
+
+    for post in sender.post_set.filter(checkpoints=True):
+        threads.append(post.thread_id)
+        prev_post = Post.objects.filter(thread=post.thread_id).exclude(merge__gt=post.merge).exclude(user=sender).order_by('merge', '-id')[:1][0]
+        post.checkpoint_set.update(post=prev_post)
+        if not prev_post.pk in prev_posts:
+            prev_posts.append(prev_post.pk)
+
+    sender.post_set.all().delete()
+    Post.objects.filter(id__in=prev_posts).update(checkpoints=True)
+
+    for post in sender.post_set.distinct().values('thread_id').iterator():
+        if not post['thread_id'] in threads:
+            threads.append(post['thread_id'])
+
+    for post in Post.objects.filter(user=sender):
+        post.delete()
+
+    for thread in Thread.objects.filter(id__in=threads):
+        thread.sync()
+        thread.save(force_update=True)
+
+delete_user_content.connect(delete_user_content_handler, dispatch_uid="delete_user_posts")
+
+
+def move_forum_content_handler(sender, **kwargs):
+    Post.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_posts")
+
+
+def move_thread_handler(sender, **kwargs):
+    Post.objects.filter(thread=sender).update(forum=kwargs['move_to'])
+
+move_thread.connect(move_thread_handler, dispatch_uid="move_thread_posts")
+
+
+def merge_thread_handler(sender, **kwargs):
+    Post.objects.filter(thread=sender).update(thread=kwargs['new_thread'], merge=F('merge') + kwargs['merge'])
+
+merge_thread.connect(merge_thread_handler, dispatch_uid="merge_threads_posts")

+ 13 - 13
misago/prune/models.py → misago/models/pruningpolicymodel.py

@@ -4,24 +4,24 @@ from django.db import models
 from django.db.models import Q
 from django.utils import timezone
 from django.utils.translation import ugettext_lazy as _
-from misago.users.models import User
 
-class Policy(models.Model):
-    """
-    Pruning policy
-    """
+class PruningPolicy(models.Model):
     name = models.CharField(max_length=255)
     email = models.CharField(max_length=255, null=True, blank=True)
     posts = models.PositiveIntegerField(default=0)
     registered = models.PositiveIntegerField(default=0)
     last_visit = models.PositiveIntegerField(default=0)
 
+    class Meta:
+        app_label = 'misago'
+
     def clean(self):
         if not (self.email and self.posts and self.registered and self.last_visit):
             raise ValidationError(_("Pruning policy must have at least one pruning criteria set to be valid."))
 
-    def get_model(self):
-        model = User.objects
+    def make_queryset(self):
+        from misago.models import User
+        queryset = User.objects
 
         if self.email:
             if ',' in self.email:
@@ -34,19 +34,19 @@ class Policy(models.Model):
                         else:
                             qs = Q(email__iendswith=name)
                 if qs:
-                    model = model.filter(qs)
+                    queryset = queryset.filter(qs)
             else:
-                model = model.filter(email__iendswith=self.email)
+                queryset = queryset.filter(email__iendswith=self.email)
 
         if self.posts:
-            model = model.filter(posts__lt=self.posts)
+            queryset = queryset.filter(posts__lt=self.posts)
 
         if self.registered:
             date = timezone.now() - timedelta(days=self.registered)
-            model = model.filter(join_date__gte=date)
+            queryset = queryset.filter(join_date__gte=date)
 
         if self.last_visit:
             date = timezone.now() - timedelta(days=self.last_visit)
-            model = model.filter(last_date__gte=date)
+            queryset = queryset.filter(last_date__gte=date)
 
-        return model
+        return queryset

+ 6 - 1
misago/ranks/models.py → misago/models/rankmodel.py

@@ -9,7 +9,7 @@ class Rank(models.Model):
     Ranks are ready style/title pairs that are assigned to users either by admin (special ranks) or as result of user activity.
     """
     name = models.CharField(max_length=255)
-    name_slug = models.CharField(max_length=255, null=True, blank=True)
+    slug = models.CharField(max_length=255, null=True, blank=True)
     description = models.TextField(null=True, blank=True)
     style = models.CharField(max_length=255, null=True, blank=True)
     title = models.CharField(max_length=255, null=True, blank=True)
@@ -19,6 +19,9 @@ class Rank(models.Model):
     order = models.IntegerField(default=0)
     criteria = models.CharField(max_length=255, null=True, blank=True)
 
+    class Meta:
+        app_label = 'misago'
+
     def __unicode__(self):
         return unicode(_(self.name))
 
@@ -87,5 +90,7 @@ class Rank(models.Model):
                         LIMIT %s''', [self.id, criteria])
             except Exception as e:
                 print 'Error updating users ranking: %s' % e
+
             transaction.commit_unless_managed()
+
         return True

+ 18 - 12
misago/roles/models.py → misago/models/rolemodel.py

@@ -11,30 +11,36 @@ class Role(models.Model):
     Misago User Role model
     """
     name = models.CharField(max_length=255)
-    token = models.CharField(max_length=255,null=True,blank=True)
+    _special = models.CharField(db_column='special', max_length=255,null=True,blank=True)
     protected = models.BooleanField(default=False)
-    permissions = models.TextField(null=True,blank=True)
+    _permissions = models.TextField(db_column='permissions', null=True, blank=True)
     permissions_cache = {}
+
+    class Meta:
+        app_label = 'misago'
     
     def __unicode__(self):
         return unicode(_(self.name))
     
-    def is_special(self):
-        return token
-    
-    def get_permissions(self):
+    @property
+    def special(self):
+        return self._special
+
+    @property
+    def permissions(self):
         if self.permissions_cache:
             return self.permissions_cache
-        
+
         try:
-            self.permissions_cache = pickle.loads(base64.decodestring(self.permissions))
+            self.permissions_cache = pickle.loads(base64.decodestring(self._permissions))
         except Exception:
             # ValueError, SuspiciousOperation, unpickling exceptions. If any of
             # these happen, just return an empty dictionary (an empty permissions list).
             self.permissions_cache = {}
-            
+
         return self.permissions_cache
-    
-    def set_permissions(self, permissions):
+
+    @permissions.setter
+    def permissions(self, permissions):
         self.permissions_cache = permissions
-        self.permissions = base64.encodestring(pickle.dumps(permissions, pickle.HIGHEST_PROTOCOL))
+        self._permissions = base64.encodestring(pickle.dumps(permissions, pickle.HIGHEST_PROTOCOL))

+ 4 - 7
misago/sessions/models.py → misago/models/sessionmodel.py

@@ -3,20 +3,17 @@ from django.db import models
 class Session(models.Model):
     id = models.CharField(max_length=42, primary_key=True)
     data = models.TextField(db_column="session_data")
-    user = models.ForeignKey('users.User', related_name='sessions', null=True, on_delete=models.SET_NULL)
+    user = models.ForeignKey('User', related_name='sessions', null=True, on_delete=models.SET_NULL)
     crawler = models.CharField(max_length=255, blank=True, null=True)
     ip = models.GenericIPAddressField()
     agent = models.CharField(max_length=255)
     start = models.DateTimeField()
     last = models.DateTimeField()
     team = models.BooleanField(default=False)
-    rank = models.ForeignKey('ranks.Rank', related_name='sessions', null=True, on_delete=models.SET_NULL)
+    rank = models.ForeignKey('Rank', related_name='sessions', null=True, on_delete=models.SET_NULL)
     admin = models.BooleanField(default=False)
     matched = models.BooleanField(default=False)
     hidden = models.BooleanField(default=False)
 
-class Token(models.Model):
-    id = models.CharField(max_length=42, primary_key=True)
-    user = models.ForeignKey('users.User', related_name='signin_tokens')
-    created = models.DateTimeField()
-    accessed = models.DateTimeField()
+    class Meta:
+        app_label = 'misago'

+ 26 - 34
misago/settings/models.py → misago/models/settingmodel.py

@@ -4,58 +4,50 @@ from django.core import validators
 from django.db import models
 from django.utils.translation import ugettext_lazy as _
 from misago.forms import YesNoSwitch
-from misago.timezones import tzlist
+from misago.utils.timezones import tzlist
 try:
     import cPickle as pickle
 except ImportError:
     import pickle
 
-class Group(models.Model):
-    key = models.CharField(max_length=255, unique=True)
-    name = models.CharField(max_length=255)
-    description = models.TextField(null=True, blank=True)
-
-    def is_active(self, active_group):
-        try:
-            return self.pk == active_group.pk
-        except AttributeError:
-            return False
-
 class Setting(models.Model):
     setting = models.CharField(max_length=255, primary_key=True)
-    group = models.ForeignKey('Group', to_field='key')
+    group = models.ForeignKey('SettingsGroup', to_field='key')
     value = models.TextField(null=True, blank=True)
     value_default = models.TextField(null=True, blank=True)
-    type = models.CharField(max_length=255)
-    input = models.CharField(max_length=255)
+    normalize_to = models.CharField(max_length=255)
+    field = models.CharField(max_length=255)
     extra = models.TextField(null=True, blank=True)
     position = models.IntegerField(default=0)
     separator = models.CharField(max_length=255, null=True, blank=True)
     name = models.CharField(max_length=255)
     description = models.TextField(null=True, blank=True)
 
+    class Meta:
+        app_label = 'misago'
+
     def get_extra(self):
         return pickle.loads(base64.decodestring(self.extra))
 
     def get_value(self):
-        if self.type == 'array':
+        if self.normalize_to == 'array':
             return self.value.split(',')
-        if self.type == 'integer':
+        if self.normalize_to == 'integer':
             return int(self.value)
-        if self.type == 'float':
+        if self.normalize_to == 'float':
             return float(self.value)
-        if self.type == 'boolean':
+        if self.normalize_to == 'boolean':
             return self.value == "1"
         return self.value
 
     def set_value(self, value):
-        if self.type == 'array':
+        if self.normalize_to == 'array':
             self.value = ','.join(value)
-        elif self.type == 'integer':
+        elif self.normalize_to == 'integer':
             self.value = int(value)
-        elif self.type == 'float':
+        elif self.normalize_to == 'float':
             self.value = float(value)
-        elif self.type == 'boolean':
+        elif self.normalize_to == 'boolean':
             self.value = 1 if value else 0
         else:
             self.value = value
@@ -69,18 +61,18 @@ class Setting(models.Model):
         # Set validators
         field_validators = []
         if 'min' in extra:
-            if self.type == 'string' or self.type == 'array':
+            if self.normalize_to == 'string' or self.normalize_to == 'array':
                 field_validators.append(validators.MinLengthValidator(extra['min']))
-            if self.type == 'integer' or self.type == 'float':
+            if self.normalize_to == 'integer' or self.normalize_to == 'float':
                 field_validators.append(validators.MinValueValidator(extra['min']))
         if 'max' in extra:
-            if self.type == 'string' or self.type == 'array':
+            if self.normalize_to == 'string' or self.normalize_to == 'array':
                 field_validators.append(validators.MaxLengthValidator(extra['max']))
-            if self.type == 'integer' or self.type == 'float':
+            if self.normalize_to == 'integer' or self.normalize_to == 'float':
                 field_validators.append(validators.MaxValueValidator(extra['max']))
 
         # Yes-no
-        if self.input == 'yesno':
+        if self.field == 'yesno':
             return forms.BooleanField(
                                    initial=self.get_value(),
                                    label=_(self.name),
@@ -90,7 +82,7 @@ class Setting(models.Model):
                                    )
 
         # Multi-list
-        if self.input == 'mlist':
+        if self.field == 'mlist':
             return forms.MultipleChoiceField(
                                      initial=self.get_value(),
                                      label=_(self.name),
@@ -102,7 +94,7 @@ class Setting(models.Model):
                                      )
 
         # Select or choice
-        if self.input == 'select' or self.input == 'choice':
+        if self.field == 'select' or self.field == 'choice':
             # Timezone list?
             if extra['choices'] == '#TZ#':
                 extra['choices'] = tzlist()
@@ -110,14 +102,14 @@ class Setting(models.Model):
                                      initial=self.get_value(),
                                      label=_(self.name),
                                      help_text=_(self.description) if self.description else None,
-                                     widget=forms.RadioSelect if self.input == 'choice' else forms.Select,
+                                     widget=forms.RadioSelect if self.field == 'choice' else forms.Select,
                                      validators=field_validators,
                                      required=False,
                                      choices=extra['choices']
                                      )
 
         # Textarea
-        if self.input == 'textarea':
+        if self.field == 'textarea':
             return forms.CharField(
                                    initial=self.get_value(),
                                    label=_(self.name),
@@ -129,9 +121,9 @@ class Setting(models.Model):
 
         # Default input
         default_input = forms.CharField
-        if self.type == 'integer':
+        if self.normalize_to == 'integer':
             default_input = forms.IntegerField
-        if self.type == 'float':
+        if self.normalize_to == 'float':
             default_input = forms.FloatField
 
         # Make text-input

+ 15 - 0
misago/models/settingsgroupmodel.py

@@ -0,0 +1,15 @@
+from django.db import models
+
+class SettingsGroup(models.Model):
+    key = models.CharField(max_length=255, unique=True)
+    name = models.CharField(max_length=255)
+    description = models.TextField(null=True, blank=True)
+
+    class Meta:
+        app_label = 'misago'
+
+    def is_active(self, active_group):
+        try:
+            return self.pk == active_group.pk
+        except AttributeError:
+            return False

+ 6 - 22
misago/bruteforce/models.py → misago/models/signinattemptmodel.py

@@ -1,18 +1,13 @@
 from datetime import timedelta
-from random import randint
 from django.db import models
 from django.utils import timezone
-from django.utils.translation import ugettext_lazy as _
 
-"""
-IP's that have exhausted their quota of sign-in attempts are automatically banned for set amount of time.
-
-That IP ban cuts bad IP address from signing into board by either making another sign-in attempts or
-registering "fresh" account.
-"""
 class SignInAttemptsManager(models.Manager):
     """
-    Attempts manager
+    IP's that have exhausted their quota of sign-in attempts are automatically banned for set amount of time.
+
+    That IP ban cuts bad IP address from signing into board by either making another sign-in attempts or
+    registering "fresh" account.
     """
     def register_attempt(self, ip):
         attempt = SignInAttempt(ip=ip, date=timezone.now())
@@ -39,16 +34,5 @@ class SignInAttempt(models.Model):
 
     objects = SignInAttemptsManager()
 
-
-class JamCache(object):
-    jammed = False
-    expires = timezone.now()
-    def check_for_updates(self, request):
-        if self.expires < timezone.now():
-            self.jammed = SignInAttempt.objects.is_jammed(request.settings, request.session.get_ip(request))
-            self.expires = timezone.now() + timedelta(minutes=request.settings['jams_lifetime'])
-            return True
-        return False
-
-    def is_jammed(self):
-        return self.jammed
+    class Meta:
+        app_label = 'misago'

+ 3 - 3
misago/themes/models.py → misago/models/themeadjustmentmodel.py

@@ -3,13 +3,13 @@ from django.db import models
 from django.utils.translation import ugettext_lazy as _
 
 class ThemeAdjustment(models.Model):
-    """
-    ThemeAdjustment - theme that is set for specified user agents
-    """
     theme = models.CharField(max_length=255, unique=True,
                              error_messages={'unique': _("User agents for this theme are already defined.")})
     useragents = models.TextField(null=True, blank=True)
     
+    class Meta:
+        app_label = 'misago'
+
     def adjust_theme(self, useragent):
         for string in self.useragents.splitlines():
             if string in useragent:

+ 208 - 0
misago/models/threadmodel.py

@@ -0,0 +1,208 @@
+from datetime import timedelta
+from django.conf import settings
+from django.db import models
+from django.db.models.signals import pre_delete
+from django.utils import timezone
+from django.utils.translation import ugettext_lazy as _
+from misago.signals import (delete_user_content, merge_thread, move_forum_content,
+                            move_thread, rename_user)
+from misago.utils.strings import slugify
+
+class ThreadManager(models.Manager):
+    def filter_stats(self, start, end):
+        return self.filter(start__gte=start).filter(start__lte=end)
+
+    def with_reads(self, queryset, user):
+        from misago.models import ForumRead, ThreadRead
+
+        threads = []
+        threads_dict = {}
+        forum_reads = {}
+        cutoff = timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)
+
+        if user.is_authenticated() and user.join_date > cutoff:
+            cutoff = user.join_date
+            for row in ForumRead.objects.filter(user=user).values('forum_id', 'cleared'):
+                forum_reads[row['forum_id']] = row['cleared']
+
+        for thread in queryset:
+            thread.is_read = True
+            if user.is_authenticated() and thread.last > cutoff:
+                try:
+                    thread.is_read = thread.last <= forum_reads[thread.forum_id]
+                except KeyError:
+                    pass
+
+            threads.append(thread)
+            threads_dict[thread.pk] = thread
+
+        if user.is_authenticated():
+            for read in ThreadRead.objects.filter(user=user).filter(thread__in=threads_dict.keys()):
+                try:
+                    threads_dict[read.thread_id].is_read = (threads_dict[read.thread_id].last <= cutoff or 
+                                                            threads_dict[read.thread_id].last <= read.updated or
+                                                            threads_dict[read.thread_id].last <= forum_reads[read.forum_id])
+                except KeyError:
+                    pass
+
+        return threads
+
+
+class Thread(models.Model):
+    forum = models.ForeignKey('Forum')
+    weight = models.PositiveIntegerField(default=0)
+    name = models.CharField(max_length=255)
+    slug = models.SlugField(max_length=255)
+    replies = models.PositiveIntegerField(default=0)
+    replies_reported = models.PositiveIntegerField(default=0)
+    replies_moderated = models.PositiveIntegerField(default=0)
+    replies_deleted = models.PositiveIntegerField(default=0)
+    merges = models.PositiveIntegerField(default=0)
+    score = models.PositiveIntegerField(default=30)
+    upvotes = models.PositiveIntegerField(default=0)
+    downvotes = models.PositiveIntegerField(default=0)
+    start = models.DateTimeField()
+    start_post = models.ForeignKey('Post', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
+    start_poster = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL)
+    start_poster_name = models.CharField(max_length=255)
+    start_poster_slug = models.SlugField(max_length=255)
+    start_poster_style = models.CharField(max_length=255, null=True, blank=True)
+    last = models.DateTimeField()
+    last_post = models.ForeignKey('Post', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
+    last_poster = models.ForeignKey('User', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
+    last_poster_name = models.CharField(max_length=255, null=True, blank=True)
+    last_poster_slug = models.SlugField(max_length=255, null=True, blank=True)
+    last_poster_style = models.CharField(max_length=255, null=True, blank=True)
+    participants = models.ManyToManyField('User', related_name='private_thread_set')
+    moderated = models.BooleanField(default=False)
+    deleted = models.BooleanField(default=False)
+    closed = models.BooleanField(default=False)
+
+    objects = ThreadManager()
+
+    statistics_name = _('New Threads')
+
+    class Meta:
+        app_label = 'misago'
+
+    def get_date(self):
+        return self.start
+
+    def new_start_post(self, post):
+        self.start = post.date
+        self.start_post = post
+        self.start_poster = post.user
+        self.start_poster_name = post.user.username
+        self.start_poster_slug = post.user.username_slug
+        if post.user.rank_id and post.user.rank.style:
+            self.start_poster_style = post.user.rank.style
+
+    def new_last_post(self, post):
+        self.last = post.date
+        self.last_post = post
+        self.last_poster = post.user
+        self.last_poster_name = post.user.username
+        self.last_poster_slug = post.user.username_slug
+        if post.user.rank_id and post.user.rank.style:
+            self.last_poster_style = post.user.rank.style
+
+    def move_to(self, move_to):
+        move_thread.send(sender=self, move_to=move_to)
+        self.forum = move_to
+
+    def merge_with(self, thread, merge):
+        merge_thread.send(sender=self, new_thread=thread, merge=merge)
+
+    def sync(self):
+        # Counters
+        self.replies = self.post_set.filter(moderated=False).count() - 1
+        if self.replies < 0:
+            self.replies = 0
+        self.replies_reported = self.post_set.filter(reported=True).count()
+        self.replies_moderated = self.post_set.filter(moderated=True).count()
+        self.replies_deleted = self.post_set.filter(deleted=True).count()
+        # First post
+        start_post = self.post_set.order_by('merge', 'id')[0:][0]
+        self.start = start_post.date
+        self.start_post = start_post
+        self.start_poster = start_post.user
+        self.start_poster_name = start_post.user_name
+        self.start_poster_slug = slugify(start_post.user_name)
+        self.start_poster_style = start_post.user.rank.style if start_post.user and start_post.user.rank else ''
+        self.upvotes = start_post.upvotes
+        self.downvotes = start_post.downvotes
+        # Last visible post
+        if self.replies > 0:
+            last_post = self.post_set.order_by('-merge', '-id').filter(moderated=False)[0:][0]
+        else:
+            last_post = start_post
+        self.last = last_post.date
+        self.last_post = last_post
+        self.last_poster = last_post.user
+        self.last_poster_name = last_post.user_name
+        self.last_poster_slug = slugify(last_post.user_name)
+        self.last_poster_style = last_post.user.rank.style if last_post.user and last_post.user.rank else ''
+        # Flags
+        self.moderated = start_post.moderated
+        self.deleted = start_post.deleted
+        self.merges = last_post.merge
+        
+    def email_watchers(self, request, thread_type, post):
+        from misago.acl.builder import acl
+        from misago.acl.exceptions import ACLError403, ACLError404
+        from misago.models import ThreadRead, WatchedThread
+
+        for watch in WatchedThread.objects.filter(thread=self).filter(email=True).filter(last_read__gte=self.previous_last):
+            user = watch.user
+            if user.pk != request.user.pk:
+                try:
+                    acl = acl(request, user)
+                    acl.forums.allow_forum_view(self.forum)
+                    acl.threads.allow_thread_view(user, self)
+                    acl.threads.allow_post_view(user, self, post)
+                    if not user.is_ignoring(request.user):
+                        user.email_user(
+                            request,
+                            '%s_reply_notification' % thread_type,
+                            _('New reply in thread "%(thread)s"') % {'thread': self.name},
+                            {'author': request.user, 'post': post, 'thread': self}
+                            )
+                except (ACLError403, ACLError404):
+                    pass
+
+
+def rename_user_handler(sender, **kwargs):
+    Thread.objects.filter(start_poster=sender).update(
+                                                     start_poster_name=sender.username,
+                                                     start_poster_slug=sender.username_slug,
+                                                     )
+    Thread.objects.filter(last_poster=sender).update(
+                                                     last_poster_name=sender.username,
+                                                     last_poster_slug=sender.username_slug,
+                                                     )
+
+rename_user.connect(rename_user_handler, dispatch_uid="rename_user_threads")
+
+
+def delete_user_content_handler(sender, **kwargs):
+    for thread in Thread.objects.filter(start_poster=sender):
+        thread.delete()
+
+delete_user_content.connect(delete_user_content_handler, dispatch_uid="delete_user_threads")
+
+
+def move_forum_content_handler(sender, **kwargs):
+    Thread.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_threads")
+
+
+def delete_user_handler(sender, instance, using, **kwargs):
+    from misago.models import User
+    if sender == User:
+        for thread in instance.private_thread_set.all():
+            thread.participants.remove(instance)
+            if not thread.participants.count():
+                thread.delete()
+
+pre_delete.connect(delete_user_handler, dispatch_uid="delete_user_participations")

+ 23 - 0
misago/models/threadreadmodel.py

@@ -0,0 +1,23 @@
+from django.db import models
+from misago.signals import move_forum_content, move_thread
+
+class ThreadRead(models.Model):
+    user = models.ForeignKey('User')
+    forum = models.ForeignKey('Forum')
+    thread = models.ForeignKey('Thread')
+    updated = models.DateTimeField()
+
+    class Meta:
+        app_label = 'misago'
+
+
+def move_forum_content_handler(sender, **kwargs):
+    ThreadRead.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_threads_reads")
+
+
+def move_thread_handler(sender, **kwargs):
+    ThreadRead.objects.filter(thread=sender).delete()
+
+move_thread.connect(move_thread_handler, dispatch_uid="move_thread_reads")

+ 10 - 0
misago/models/tokenmodel.py

@@ -0,0 +1,10 @@
+from django.db import models
+
+class Token(models.Model):
+    id = models.CharField(max_length=42, primary_key=True)
+    user = models.ForeignKey('User', related_name='signin_tokens')
+    created = models.DateTimeField()
+    accessed = models.DateTimeField()
+
+    class Meta:
+        app_label = 'misago'

+ 37 - 17
misago/users/models.py → misago/models/usermodel.py

@@ -13,13 +13,11 @@ from django.template import RequestContext
 from django.utils import timezone as tz_util
 from django.utils.translation import ugettext_lazy as _
 from misago.acl.builder import build_acl
-from misago.monitor.monitor import Monitor
-from misago.roles.models import Role
-from misago.settings.settings import Settings as DBSettings
-from misago.users.signals import delete_user_content, rename_user
-from misago.users.validators import validate_username, validate_password, validate_email
-from misago.utils import get_random_string, slugify
+from misago.monitor import Monitor
+from misago.signals import delete_user_content, rename_user
 from misago.utils.avatars import avatar_size
+from misago.utils.strings import random_string, slugify
+from misago.validators import validate_username, validate_password, validate_email
 
 class UserManager(models.Manager):
     """
@@ -43,11 +41,12 @@ class UserManager(models.Manager):
     def create_user(self, username, email, password, timezone=False, ip='127.0.0.1', agent='', no_roles=False, activation=0, request=False):
         token = ''
         if activation > 0:
-            token = get_random_string(12)
+            token = random_string(12)
 
         try:
             db_settings = request.settings
         except AttributeError:
+            from misago.dbsettings import DBSettings
             db_settings = DBSettings()
 
         if timezone == False:
@@ -55,7 +54,7 @@ class UserManager(models.Manager):
 
         # Get first rank
         try:
-            from misago.ranks.models import Rank
+            from misago.models import Rank
             default_rank = Rank.objects.filter(special=0).order_by('order')[0]
         except IndexError:
             default_rank = None
@@ -85,8 +84,8 @@ class UserManager(models.Manager):
 
         # Set user roles?
         if not no_roles:
-            from misago.roles.models import Role
-            new_user.roles.add(Role.objects.get(token='registered'))
+            from misago.models import Role
+            new_user.roles.add(Role.objects.get(_special='registered'))
             new_user.make_acl_key()
             new_user.save(force_update=True)
 
@@ -140,7 +139,6 @@ class User(models.Model):
     last_ip = models.GenericIPAddressField(null=True, blank=True)
     last_agent = models.TextField(null=True, blank=True)
     hide_activity = models.PositiveIntegerField(default=0)
-    allow_pms = models.PositiveIntegerField(default=0)
     subscribe_start = models.PositiveIntegerField(default=0)
     subscribe_reply = models.PositiveIntegerField(default=0)
     receive_newsletters = models.BooleanField(default=True)
@@ -155,7 +153,7 @@ class User(models.Model):
     followers = models.PositiveIntegerField(default=0)
     score = models.IntegerField(default=0)
     ranking = models.PositiveIntegerField(default=0)
-    rank = models.ForeignKey('ranks.Rank', null=True, blank=True, on_delete=models.SET_NULL)
+    rank = models.ForeignKey('Rank', null=True, blank=True, on_delete=models.SET_NULL)
     last_sync = models.DateTimeField(null=True, blank=True)
     follows = models.ManyToManyField('self', related_name='follows_set', symmetrical=False)
     ignores = models.ManyToManyField('self', related_name='ignores_set', symmetrical=False)
@@ -164,6 +162,9 @@ class User(models.Model):
     last_search = models.DateTimeField(null=True, blank=True)
     alerts = models.PositiveIntegerField(default=0)
     alerts_date = models.DateTimeField(null=True, blank=True)
+    allow_pds = models.PositiveIntegerField(default=0)
+    unread_pds = models.PositiveIntegerField(default=0)
+    sync_pds = models.BooleanField(default=False)
     activation = models.IntegerField(default=0)
     token = models.CharField(max_length=12, null=True, blank=True)
     avatar_ban = models.BooleanField(default=False)
@@ -173,7 +174,7 @@ class User(models.Model):
     signature_ban_reason_user = models.TextField(null=True, blank=True)
     signature_ban_reason_admin = models.TextField(null=True, blank=True)
     timezone = models.CharField(max_length=255, default='utc')
-    roles = models.ManyToManyField('roles.Role')
+    roles = models.ManyToManyField('Role')
     is_team = models.BooleanField(default=False)
     acl_key = models.CharField(max_length=12, null=True, blank=True)
 
@@ -186,6 +187,9 @@ class User(models.Model):
 
     statistics_name = _('Users Registrations')
 
+    class Meta:
+        app_label = 'misago'
+
     def is_god(self):
         try:
             return self.is_god_cache
@@ -317,7 +321,6 @@ class User(models.Model):
         self.username_slug = slugify(username)
 
     def sync_username(self):
-        print 'SYNCING NAME CACHES!'
         rename_user.send(sender=self)
 
     def is_username_valid(self, e):
@@ -401,6 +404,18 @@ class User(models.Model):
     def ignored_users(self):
         return [item['id'] for item in self.ignores.values('id')]
 
+    def allow_pd_invite(self, user):
+        # PD's from nobody
+        if self.allow_pds == 3:
+            return False
+        # PD's from followed
+        if self.allow_pds == 2:
+            return self.is_following(user)
+        # PD's from non-ignored
+        if self.allow_pds == 1:
+            return not self.is_ignoring(user)
+        return True
+
     def get_roles(self):
         return self.roles.all()
 
@@ -413,7 +428,7 @@ class User(models.Model):
         self.acl_key = 'acl_%s' % hashlib.md5('_'.join(roles_ids)).hexdigest()[0:8]
         return self.acl_key
 
-    def get_acl(self, request):
+    def acl(self, request):
         try:
             acl = cache.get(self.acl_key)
             if acl.version != request.monitor.acl_version:
@@ -482,10 +497,14 @@ class User(models.Model):
         return activations[self.activation]
 
     def alert(self, message):
-        from misago.alerts.models import Alert
+        from misago.models import Alert
         self.alerts += 1
         return Alert(user=self, message=message, date=tz_util.now())
 
+    def sync_unread_pds(self, unread):
+        self.unread_pds = unread
+        self.sync_pds = False
+
     def get_date(self):
         return self.join_date
 
@@ -511,7 +530,8 @@ class Guest(object):
         return False
 
     def get_roles(self):
-        return Role.objects.filter(token='guest')
+        from misago.models import Role
+        return Role.objects.filter(_special='guest')
 
     def make_acl_key(self):
         return 'acl_guest'

+ 4 - 1
misago/usercp/models.py → misago/models/usernamechangemodel.py

@@ -1,6 +1,9 @@
 from django.db import models
 
 class UsernameChange(models.Model):
-    user = models.ForeignKey('users.User', related_name='namechanges')
+    user = models.ForeignKey('User', related_name='namechanges')
     date = models.DateTimeField()
     old_username = models.CharField(max_length=255)
+
+    class Meta:
+        app_label = 'misago'

+ 35 - 0
misago/models/watchedthreadmodel.py

@@ -0,0 +1,35 @@
+from django.db import models
+from misago.signals import merge_thread, move_forum_content, move_thread
+
+class WatchedThread(models.Model):
+    user = models.ForeignKey('User')
+    forum = models.ForeignKey('Forum')
+    thread = models.ForeignKey('Thread')
+    last_read = models.DateTimeField()
+    email = models.BooleanField(default=False)
+    deleted = False
+
+    class Meta:
+        app_label = 'misago'
+    
+    def save(self, *args, **kwargs):
+        if not self.deleted:
+            super(WatchedThread, self).save(*args, **kwargs)
+            
+
+def move_forum_content_handler(sender, **kwargs):
+    WatchedThread.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_threads_watchers")
+
+
+def move_thread_handler(sender, **kwargs):
+    WatchedThread.objects.filter(forum=sender.forum_id).update(forum=kwargs['move_to'])
+
+move_thread.connect(move_thread_handler, dispatch_uid="move_thread_watchers")
+
+
+def merge_thread_handler(sender, **kwargs):
+    WatchedThread.objects.filter(thread=sender).delete()
+
+merge_thread.connect(merge_thread_handler, dispatch_uid="merge_threads_watchers")

+ 3 - 4
misago/monitor/monitor.py → misago/monitor.py

@@ -1,6 +1,6 @@
 from django.core.cache import cache
 from django.utils import timezone
-from misago.monitor.models import Item
+from misago.models import MonitorItem
 
 class Monitor(object):
     def __init__(self):
@@ -12,7 +12,7 @@ class Monitor(object):
         self._items = cache.get('monitor')
         if not self._items:
             self._items = {}
-            for i in Item.objects.all():
+            for i in MonitorItem.objects.all():
                 self._items[i.id] = [i.value, i.updated]
             cache.set('monitor', self._items)
 
@@ -25,7 +25,7 @@ class Monitor(object):
     def __setitem__(self, key, value):
         self._items[key][0] = value
         cache.set('monitor', self._items)
-        sync_item = Item(id=key, value=value, updated=timezone.now())
+        sync_item = MonitorItem(id=key, value=value, updated=timezone.now())
         sync_item.save(force_update=True)
         return value
 
@@ -62,4 +62,3 @@ class Monitor(object):
 
     def iteritems(self):
         return self._items.iteritems()
-

+ 0 - 7
misago/monitor/context_processors.py

@@ -1,7 +0,0 @@
-def monitor(request):
-    try:
-        return {
-            'monitor' : request.monitor,
-        }
-    except AttributeError:
-        return {}

+ 0 - 11
misago/monitor/fixtures.py

@@ -1,11 +0,0 @@
-from django.utils import timezone
-from misago.monitor.models import Item
-
-def load_monitor_fixture(fixture):
-    for id in fixture.keys():
-        item = Item(
-                    id=id,
-                    value=fixture[id],
-                    updated=timezone.now()
-                    )
-        item.save(force_insert=True)

+ 0 - 34
misago/monitor/migrations/0001_initial.py

@@ -1,34 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Item'
-        db.create_table(u'monitor_item', (
-            ('id', self.gf('django.db.models.fields.CharField')(max_length=255, primary_key=True)),
-            ('value', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('updated', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
-        ))
-        db.send_create_signal(u'monitor', ['Item'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Item'
-        db.delete_table(u'monitor_item')
-
-
-    models = {
-        u'monitor.item': {
-            'Meta': {'object_name': 'Item'},
-            'id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'primary_key': 'True'}),
-            'updated': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
-        }
-    }
-
-    complete_apps = ['monitor']

+ 0 - 70
misago/newsletters/migrations/0001_initial.py

@@ -1,70 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Newsletter'
-        db.create_table(u'newsletters_newsletter', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('token', self.gf('django.db.models.fields.CharField')(max_length=32)),
-            ('step_size', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('progress', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('content_html', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('content_plain', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('ignore_subscriptions', self.gf('django.db.models.fields.BooleanField')(default=False)),
-        ))
-        db.send_create_signal(u'newsletters', ['Newsletter'])
-
-        # Adding M2M table for field ranks on 'Newsletter'
-        db.create_table(u'newsletters_newsletter_ranks', (
-            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
-            ('newsletter', models.ForeignKey(orm[u'newsletters.newsletter'], null=False)),
-            ('rank', models.ForeignKey(orm[u'ranks.rank'], null=False))
-        ))
-        db.create_unique(u'newsletters_newsletter_ranks', ['newsletter_id', 'rank_id'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Newsletter'
-        db.delete_table(u'newsletters_newsletter')
-
-        # Removing M2M table for field ranks on 'Newsletter'
-        db.delete_table('newsletters_newsletter_ranks')
-
-
-    models = {
-        u'newsletters.newsletter': {
-            'Meta': {'object_name': 'Newsletter'},
-            'content_html': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'content_plain': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ignore_subscriptions': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'progress': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'ranks': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['ranks.Rank']", 'symmetrical': 'False'}),
-            'step_size': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '32'})
-        },
-        u'ranks.rank': {
-            'Meta': {'object_name': 'Rank'},
-            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        }
-    }
-
-    complete_apps = ['newsletters']

+ 0 - 40
misago/prune/migrations/0001_initial.py

@@ -1,40 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Policy'
-        db.create_table(u'prune_policy', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('email', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('posts', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('registered', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('last_visit', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-        ))
-        db.send_create_signal(u'prune', ['Policy'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Policy'
-        db.delete_table(u'prune_policy')
-
-
-    models = {
-        u'prune.policy': {
-            'Meta': {'object_name': 'Policy'},
-            'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last_visit': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'registered': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        }
-    }
-
-    complete_apps = ['prune']

+ 0 - 50
misago/ranks/migrations/0001_initial.py

@@ -1,50 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Rank'
-        db.create_table(u'ranks_rank', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('name_slug', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('description', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('style', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('title', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('special', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('as_tab', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('on_index', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('order', self.gf('django.db.models.fields.IntegerField')(default=0)),
-            ('criteria', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-        ))
-        db.send_create_signal(u'ranks', ['Rank'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Rank'
-        db.delete_table(u'ranks_rank')
-
-
-    models = {
-        u'ranks.rank': {
-            'Meta': {'object_name': 'Rank'},
-            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        }
-    }
-
-    complete_apps = ['ranks']

+ 0 - 217
misago/readstracker/migrations/0001_initial.py

@@ -1,217 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Record'
-        db.create_table(u'readstracker_record', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['users.User'])),
-            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['forums.Forum'])),
-            ('threads', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('updated', self.gf('django.db.models.fields.DateTimeField')()),
-            ('cleared', self.gf('django.db.models.fields.DateTimeField')()),
-        ))
-        db.send_create_signal(u'readstracker', ['Record'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Record'
-        db.delete_table(u'readstracker_record')
-
-
-    models = {
-        u'forums.forum': {
-            'Meta': {'object_name': 'Forum'},
-            'attrs': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'description_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_thread': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Thread']"}),
-            'last_thread_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_thread_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_thread_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'parent': ('mptt.fields.TreeForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': u"orm['forums.Forum']"}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'posts_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'prune_last': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'prune_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'redirect': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'redirects': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'redirects_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'show_details': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'type': ('django.db.models.fields.CharField', [], {'max_length': '12'})
-        },
-        u'ranks.rank': {
-            'Meta': {'object_name': 'Rank'},
-            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'readstracker.record': {
-            'Meta': {'object_name': 'Record'},
-            'cleared': ('django.db.models.fields.DateTimeField', [], {}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'threads': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'updated': ('django.db.models.fields.DateTimeField', [], {}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']"})
-        },
-        u'roles.role': {
-            'Meta': {'object_name': 'Role'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'threads.post': {
-            'Meta': {'object_name': 'Post'},
-            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'checkpoints': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'date': ('django.db.models.fields.DateTimeField', [], {}),
-            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'edit_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'edit_reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edit_user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'edit_user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edit_user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edits': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'mentions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'mention_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'merge': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'post': ('django.db.models.fields.TextField', [], {}),
-            'post_preparsed': ('django.db.models.fields.TextField', [], {}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'reported': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Thread']"}),
-            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
-        },
-        u'threads.thread': {
-            'Meta': {'object_name': 'Thread'},
-            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last': ('django.db.models.fields.DateTimeField', [], {}),
-            'last_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Post']"}),
-            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'merges': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'replies': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_deleted': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_moderated': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_reported': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'score': ('django.db.models.fields.PositiveIntegerField', [], {'default': '30'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'start': ('django.db.models.fields.DateTimeField', [], {}),
-            'start_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Post']"}),
-            'start_poster': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'start_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'start_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'start_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'type': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'weight': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        },
-        u'users.user': {
-            'Meta': {'object_name': 'User'},
-            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'allow_pms': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
-            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
-            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
-            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
-            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ranks.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['roles.Role']", 'symmetrical': 'False'}),
-            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
-            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        }
-    }
-
-    complete_apps = ['readstracker']

+ 0 - 234
misago/readstracker/migrations/0002_auto__add_threadrecord__del_field_record_threads.py

@@ -1,234 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'ThreadRecord'
-        db.create_table(u'readstracker_threadrecord', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['users.User'])),
-            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['forums.Forum'])),
-            ('thread', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['threads.Thread'])),
-            ('updated', self.gf('django.db.models.fields.DateTimeField')()),
-        ))
-        db.send_create_signal(u'readstracker', ['ThreadRecord'])
-        
-        db.create_index('readstracker_threadrecord', ['updated'])
-        db.create_index('readstracker_threadrecord', ['user_id', 'forum_id'])
-        db.create_index('readstracker_threadrecord', ['user_id', 'thread_id', 'updated'])
-
-        # Deleting field 'Record.threads'
-        db.delete_column(u'readstracker_record', 'threads')
-
-
-    def backwards(self, orm):
-        # Deleting model 'ThreadRecord'
-        db.delete_table(u'readstracker_threadrecord')
-
-        # Adding field 'Record.threads'
-        db.add_column(u'readstracker_record', 'threads',
-                      self.gf('django.db.models.fields.TextField')(null=True, blank=True),
-                      keep_default=False)
-
-
-    models = {
-        u'forums.forum': {
-            'Meta': {'object_name': 'Forum'},
-            'attrs': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'description_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_thread': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Thread']"}),
-            'last_thread_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_thread_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_thread_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'parent': ('mptt.fields.TreeForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': u"orm['forums.Forum']"}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'posts_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'prune_last': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'prune_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'redirect': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'redirects': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'redirects_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'show_details': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'type': ('django.db.models.fields.CharField', [], {'max_length': '12'})
-        },
-        u'ranks.rank': {
-            'Meta': {'object_name': 'Rank'},
-            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'readstracker.record': {
-            'Meta': {'object_name': 'Record'},
-            'cleared': ('django.db.models.fields.DateTimeField', [], {}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'updated': ('django.db.models.fields.DateTimeField', [], {}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']"})
-        },
-        u'readstracker.threadrecord': {
-            'Meta': {'object_name': 'ThreadRecord'},
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Thread']"}),
-            'updated': ('django.db.models.fields.DateTimeField', [], {}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']"})
-        },
-        u'roles.role': {
-            'Meta': {'object_name': 'Role'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'threads.post': {
-            'Meta': {'object_name': 'Post'},
-            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'checkpoints': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'date': ('django.db.models.fields.DateTimeField', [], {}),
-            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'edit_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'edit_reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edit_user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'edit_user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edit_user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edits': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'mentions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'mention_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'merge': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'post': ('django.db.models.fields.TextField', [], {}),
-            'post_preparsed': ('django.db.models.fields.TextField', [], {}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'reported': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Thread']"}),
-            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
-        },
-        u'threads.thread': {
-            'Meta': {'object_name': 'Thread'},
-            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last': ('django.db.models.fields.DateTimeField', [], {}),
-            'last_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Post']"}),
-            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'merges': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'replies': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_deleted': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_moderated': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_reported': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'score': ('django.db.models.fields.PositiveIntegerField', [], {'default': '30'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'start': ('django.db.models.fields.DateTimeField', [], {}),
-            'start_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Post']"}),
-            'start_poster': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'start_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'start_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'start_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'weight': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        },
-        u'users.user': {
-            'Meta': {'object_name': 'User'},
-            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'allow_pms': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
-            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
-            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
-            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
-            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ranks.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['roles.Role']", 'symmetrical': 'False'}),
-            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
-            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        }
-    }
-
-    complete_apps = ['readstracker']

+ 0 - 221
misago/readstracker/migrations/0003_auto__del_record__add_forumrecord.py

@@ -1,221 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        db.rename_table('readstracker_record', 'readstracker_forumrecord')
-
-        db.create_index('readstracker_forumrecord', ['updated'])
-        db.create_index('readstracker_forumrecord', ['user_id', 'updated'])
-        db.create_index('readstracker_forumrecord', ['user_id', 'forum_id'])
-
-
-    def backwards(self, orm):
-        db.delete_index('readstracker_forumrecord', ['updated'])
-        db.delete_index('readstracker_forumrecord', ['user_id', 'updated'])
-        db.delete_index('readstracker_forumrecord', ['user_id', 'forum_id'])
-
-        db.rename_table('readstracker_forumrecord', 'readstracker_record')
-
-
-    models = {
-        u'forums.forum': {
-            'Meta': {'object_name': 'Forum'},
-            'attrs': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'description_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_thread': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Thread']"}),
-            'last_thread_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_thread_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_thread_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'parent': ('mptt.fields.TreeForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': u"orm['forums.Forum']"}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'posts_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'prune_last': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'prune_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'redirect': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'redirects': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'redirects_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'show_details': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'type': ('django.db.models.fields.CharField', [], {'max_length': '12'})
-        },
-        u'ranks.rank': {
-            'Meta': {'object_name': 'Rank'},
-            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'readstracker.forumrecord': {
-            'Meta': {'object_name': 'ForumRecord'},
-            'cleared': ('django.db.models.fields.DateTimeField', [], {}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'updated': ('django.db.models.fields.DateTimeField', [], {}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']"})
-        },
-        u'readstracker.threadrecord': {
-            'Meta': {'object_name': 'ThreadRecord'},
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Thread']"}),
-            'updated': ('django.db.models.fields.DateTimeField', [], {}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']"})
-        },
-        u'roles.role': {
-            'Meta': {'object_name': 'Role'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'threads.post': {
-            'Meta': {'object_name': 'Post'},
-            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'checkpoints': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'date': ('django.db.models.fields.DateTimeField', [], {}),
-            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'edit_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'edit_reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edit_user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'edit_user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edit_user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edits': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'mentions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'mention_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'merge': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'post': ('django.db.models.fields.TextField', [], {}),
-            'post_preparsed': ('django.db.models.fields.TextField', [], {}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'reported': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Thread']"}),
-            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
-        },
-        u'threads.thread': {
-            'Meta': {'object_name': 'Thread'},
-            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last': ('django.db.models.fields.DateTimeField', [], {}),
-            'last_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Post']"}),
-            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'merges': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'replies': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_deleted': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_moderated': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_reported': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'score': ('django.db.models.fields.PositiveIntegerField', [], {'default': '30'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'start': ('django.db.models.fields.DateTimeField', [], {}),
-            'start_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Post']"}),
-            'start_poster': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'start_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'start_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'start_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'weight': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        },
-        u'users.user': {
-            'Meta': {'object_name': 'User'},
-            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'allow_pms': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
-            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
-            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
-            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
-            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ranks.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['roles.Role']", 'symmetrical': 'False'}),
-            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
-            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        }
-    }
-
-    complete_apps = ['readstracker']

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


+ 0 - 37
misago/readstracker/models.py

@@ -1,37 +0,0 @@
-from datetime import timedelta
-from django.conf import settings
-from django.db import models
-from django.utils import timezone
-from misago.forums.signals import move_forum_content
-from misago.threads.signals import move_thread
-
-class ThreadRecord(models.Model):
-    user = models.ForeignKey('users.User')
-    forum = models.ForeignKey('forums.Forum')
-    thread = models.ForeignKey('threads.Thread')
-    updated = models.DateTimeField()
-    
-
-class ForumRecord(models.Model):
-    user = models.ForeignKey('users.User')
-    forum = models.ForeignKey('forums.Forum')
-    updated = models.DateTimeField()
-    cleared = models.DateTimeField()
-    
-    def get_threads(self):
-        threads = {}
-        for thread in ThreadRecord.objects.filter(user=self.user, forum=self.forum, updated__gte=(timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH))):
-            threads[thread.thread_id] = thread
-        return threads
-
-
-def move_forum_content_handler(sender, **kwargs):
-    ForumRecord.objects.filter(forum=sender).delete()
-
-move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_threads_reads")
-
-
-def move_thread_handler(sender, **kwargs):
-    ThreadRecord.objects.filter(thread=sender).delete()
-
-move_thread.connect(move_thread_handler, dispatch_uid="move_thread_reads")

+ 26 - 19
misago/readstracker/trackers.py → misago/readstrackers.py

@@ -1,8 +1,7 @@
 from datetime import timedelta
 from django.conf import settings
 from django.utils import timezone
-from misago.readstracker.models import ForumRecord, ThreadRecord
-from misago.threads.models import Thread
+from misago.models import Thread, ForumRead, ThreadRead
 
 class ForumsTracker(object):
     def __init__(self, user):
@@ -12,7 +11,7 @@ class ForumsTracker(object):
         if user.is_authenticated() and settings.READS_TRACKER_LENGTH > 0:
             if user.join_date > self.cutoff:
                 self.cutoff = user.join_date
-            for forum in ForumRecord.objects.filter(user=user).filter(updated__gte=self.cutoff).values('id', 'forum_id', 'updated', 'cleared'):
+            for forum in ForumRead.objects.filter(user=user).filter(updated__gte=self.cutoff).values('id', 'forum_id', 'updated', 'cleared'):
                  self.forums[forum['forum_id']] = forum
 
     def is_read(self, forum):
@@ -35,14 +34,14 @@ class ThreadsTracker(object):
             if request.user.join_date > self.cutoff:
                 self.cutoff = request.user.join_date
             try:
-                self.record = ForumRecord.objects.get(user=request.user, forum=forum)
+                self.record = ForumRead.objects.get(user=request.user, forum=forum)
                 if self.record.cleared > self.cutoff:
                     self.cutoff = self.record.cleared
-            except ForumRecord.DoesNotExist:
-                self.record = ForumRecord(user=request.user, forum=forum, cleared=self.cutoff)
+            except ForumRead.DoesNotExist:
+                self.record = ForumRead(user=request.user, forum=forum, cleared=self.cutoff)
             self.threads = self.record.get_threads()
 
-    def get_read_date(self, thread):
+    def read_date(self, thread):
         if not self.request.user.is_authenticated():
             return timezone.now()
         try:
@@ -64,20 +63,32 @@ class ThreadsTracker(object):
         if self.request.user.is_authenticated() and post.date > self.cutoff:
             try:
                 self.threads[thread.pk].updated = post.date
-                self.need_update = thread
+                self.need_update = self.threads[thread.pk]
             except KeyError:
                 self.need_create = thread
 
+    def unread_count(self, queryset=None):
+        try:
+            return self.unread_threads
+        except AttributeError:
+            self.unread_threads = 0
+            if not queryset:
+                queryset = self.request.acl.threads.filter_threads(self.request, self.forum, self.forum.thread_set)
+            for thread in queryset.filter(last__gte=self.record.cleared):
+                if not self.is_read(thread):
+                    self.unread_threads += 1
+            return self.unread_threads
+
     def sync(self):
         now = timezone.now()
 
         if self.need_create:
-            new_record = ThreadRecord(
-                                      user=self.request.user,
-                                      thread=self.need_create,
-                                      forum=self.forum,
-                                      updated=now
-                                      )
+            new_record = ThreadRead(
+                                    user=self.request.user,
+                                    thread=self.need_create,
+                                    forum=self.forum,
+                                    updated=now
+                                    )
             new_record.save(force_insert=True)
             self.threads[new_record.thread_id] = new_record
 
@@ -86,11 +97,7 @@ class ThreadsTracker(object):
             self.need_update.save(force_update=True)
 
         if self.need_create or self.need_update:
-            unread_threads = 0
-            for thread in self.request.acl.threads.filter_threads(self.request, self.forum, self.forum.thread_set.filter(last__gte=self.record.cleared)):
-                if not self.is_read(thread):
-                    unread_threads += 1
-            if not unread_threads:
+            if not self.unread_count():
                 self.record.cleared = now
             self.record.updated = now
             if self.record.pk:

+ 0 - 0
misago/register/__init__.py


+ 0 - 5
misago/register/urls.py

@@ -1,5 +0,0 @@
-from django.conf.urls import patterns, url
-
-urlpatterns = patterns('misago.register.views',
-    url(r'^$', 'form', name="register"),
-)

+ 0 - 0
misago/resetpswd/__init__.py


+ 0 - 0
misago/roles/__init__.py


+ 0 - 50
misago/roles/fixtures.py

@@ -1,50 +0,0 @@
-from misago.roles.models import Role
-from misago.utils import ugettext_lazy as _
-from misago.utils import get_msgid
-
-def load_fixtures():
-    role = Role(name=_("Administrator").message, token='admin', protected=True)
-    role.set_permissions({
-                          'name_changes_allowed': 5,
-                          'changes_expire': 7,
-                          'can_use_acp': True,
-                          'can_use_signature': True,
-                          'allow_signature_links': True,
-                          'allow_signature_images': True,
-                          'can_search_users': True,
-                          'can_see_users_emails': True,
-                          'can_see_users_trails': True,
-                          'can_see_hidden_users': True,
-                          'forums': {5: 1, 6: 1, 7: 1},
-                          })
-    role.save(force_insert=True)
-    
-    role = Role(name=_("Moderator").message, token='mod', protected=True)
-    role.set_permissions({
-                          'name_changes_allowed': 3,
-                          'changes_expire': 14,
-                          'can_use_signature': True,
-                          'allow_signature_links': True,
-                          'can_search_users': True,
-                          'can_see_users_emails': True,
-                          'can_see_users_trails': True,
-                          'can_see_hidden_users': True,
-                          'forums': {5: 1, 6: 1, 7: 1},
-                          })
-    role.save(force_insert=True)
-    
-    role = Role(name=_("Registered").message, token='registered')
-    role.set_permissions({
-                          'name_changes_allowed': 2,
-                          'can_use_signature': False,
-                          'can_search_users': True,
-                          'forums': {5: 3, 6: 3, 7: 3},
-                          })
-    role.save(force_insert=True)
-    
-    role = Role(name=_("Guest").message, token='guest')
-    role.set_permissions({
-                          'can_search_users': True,
-                          'forums': {5: 6, 6: 6, 7: 6},
-                          })
-    role.save(force_insert=True)

+ 0 - 38
misago/roles/migrations/0001_initial.py

@@ -1,38 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Role'
-        db.create_table(u'roles_role', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('token', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('protected', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('permissions', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-        ))
-        db.send_create_signal(u'roles', ['Role'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Role'
-        db.delete_table(u'roles_role')
-
-
-    models = {
-        u'roles.role': {
-            'Meta': {'object_name': 'Role'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        }
-    }
-
-    complete_apps = ['roles']

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


+ 31 - 26
misago/sessions/sessions.py → misago/sessions.py

@@ -1,3 +1,4 @@
+from hashlib import md5
 from datetime import timedelta
 from django.conf import settings
 from django.contrib.sessions.backends.base import SessionBase, CreateError
@@ -5,10 +6,9 @@ from django.db.models.loading import cache as model_cache
 from django.utils import timezone
 from django.utils.crypto import salted_hmac
 from django.utils.encoding import force_unicode
-from misago.authn.methods import auth_remember, AuthException
-from misago.sessions.models import *
-from misago.users.models import Guest, User
-from misago.utils import get_random_string
+from misago.auth import auth_remember, AuthException
+from misago.models import Session, Token, Guest, User
+from misago.utils.strings import random_string
 
 # Assert models are loaded
 if not model_cache.loaded:
@@ -19,12 +19,12 @@ class IncorrectSessionException(Exception):
     pass
 
 
-class SessionMisago(SessionBase):
+class MisagoSession(SessionBase):
     """
     Abstract class for sessions to inherit and extend
     """
     def _get_new_session_key(self):
-        return get_random_string(42)
+        return random_string(42)
 
     def _get_session(self):
         try:
@@ -72,10 +72,13 @@ class SessionMisago(SessionBase):
     def save(self):
         self._session_rk.data = self.encode(self._get_session())
         self._session_rk.last = timezone.now()
-        self._session_rk.save(force_update=True)
+        if self._session_rk.pk:
+            self._session_rk.save(force_update=True)
+        else:
+            self._session_rk.save(force_insert=True)
 
 
-class SessionCrawler(SessionMisago):
+class CrawlerSession(MisagoSession):
     """
     Crawler Session controller
     """
@@ -83,37 +86,37 @@ class SessionCrawler(SessionMisago):
         self.hidden = False
         self.team = False
         self._ip = self.get_ip(request)
+        self._session_key = md5('%s-%s' % (request.user.username, self._ip)).hexdigest()
         try:
-            self._session_rk = Session.objects.get(crawler=request.user.username, ip=self._ip)
+            self._session_rk = Session.objects.get(id=self._session_key)
             self._session_key = self._session_rk.id
         except Session.DoesNotExist:
             self.create(request)
 
     def create(self, request):
+        self._session_rk = Session(
+                                   id=self._session_key,
+                                   data=self.encode({}),
+                                   crawler=request.user.username,
+                                   ip=self._ip,
+                                   agent=request.META.get('HTTP_USER_AGENT', ''),
+                                   start=timezone.now(),
+                                   last=timezone.now(),
+                                   matched=True
+                                   )
         while True:
             try:
-                self._session_key = self._get_new_session_key()
-                self._session_rk = Session(
-                                         id=self._session_key,
-                                         data=self.encode({}),
-                                         crawler=request.user.username,
-                                         ip=self._ip,
-                                         agent=request.META.get('HTTP_USER_AGENT', ''),
-                                         start=timezone.now(),
-                                         last=timezone.now(),
-                                         matched=True
-                                         )
                 self._session_rk.save(force_insert=True)
                 break
             except CreateError:
-                # Key wasn't unique. Try again.
+                # Key wasn't unique, we'll try again after request ends
                 continue
 
     def human_session(self):
         return False
 
 
-class SessionHuman(SessionMisago):
+class HumanSession(MisagoSession):
     """
     Human Session controller
     """
@@ -142,10 +145,12 @@ class SessionHuman(SessionMisago):
             # IP invalid
             if request.settings.sessions_validate_ip and self._session_rk.ip != self._ip:
                 raise IncorrectSessionException()
+            
             # Session expired
             if timezone.now() - self._session_rk.last > timedelta(seconds=settings.SESSION_LIFETIME):
                 self.expired = True
                 raise IncorrectSessionException()
+            
             # Change session to matched and extract session user and hidden flag
             self._session_rk.matched = True
             self._user = self._session_rk.user
@@ -163,9 +168,9 @@ class SessionHuman(SessionMisago):
 
         # Make cookie live longer
         if request.firewall.admin:
-            request.cookie_jar.set('ASID', self._session_rk.id)
+            request.cookiejar.set('ASID', self._session_rk.id)
         else:
-            request.cookie_jar.set('SID', self._session_rk.id)
+            request.cookiejar.set('SID', self._session_rk.id)
 
     def create(self, request, user=None):
         self._user = user
@@ -201,7 +206,7 @@ class SessionHuman(SessionMisago):
         self._session_rk.hidden = self.hidden
         self._session_rk.team = self.team
         self._session_rk.rank_id = self.rank
-        super(SessionHuman, self).save()
+        super(HumanSession, self).save()
 
     def human_session(self):
         return True
@@ -225,7 +230,7 @@ class SessionHuman(SessionMisago):
                     if cookie_token in request.COOKIES:
                         if len(request.COOKIES[cookie_token]) > 0:
                             Token.objects.filter(id=request.COOKIES[cookie_token]).delete()
-                        request.cookie_jar.delete('TOKEN')
+                        request.cookiejar.delete('TOKEN')
                 self.hidden = False
                 self._user = None
                 request.user = Guest()

+ 0 - 0
misago/sessions/__init__.py


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


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


+ 0 - 154
misago/sessions/migrations/0001_initial.py

@@ -1,154 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Session'
-        db.create_table(u'sessions_session', (
-            ('id', self.gf('django.db.models.fields.CharField')(max_length=42, primary_key=True)),
-            ('data', self.gf('django.db.models.fields.TextField')(db_column='session_data')),
-            ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='sessions', null=True, on_delete=models.SET_NULL, to=orm['users.User'])),
-            ('crawler', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
-            ('agent', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('start', self.gf('django.db.models.fields.DateTimeField')()),
-            ('last', self.gf('django.db.models.fields.DateTimeField')()),
-            ('team', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('rank', self.gf('django.db.models.fields.related.ForeignKey')(related_name='sessions', null=True, on_delete=models.SET_NULL, to=orm['ranks.Rank'])),
-            ('admin', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('matched', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('hidden', self.gf('django.db.models.fields.BooleanField')(default=False)),
-        ))
-        db.send_create_signal(u'sessions', ['Session'])
-
-        # Adding model 'Token'
-        db.create_table(u'sessions_token', (
-            ('id', self.gf('django.db.models.fields.CharField')(max_length=42, primary_key=True)),
-            ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='signin_tokens', to=orm['users.User'])),
-            ('created', self.gf('django.db.models.fields.DateTimeField')()),
-            ('accessed', self.gf('django.db.models.fields.DateTimeField')()),
-        ))
-        db.send_create_signal(u'sessions', ['Token'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Session'
-        db.delete_table(u'sessions_session')
-
-        # Deleting model 'Token'
-        db.delete_table(u'sessions_token')
-
-
-    models = {
-        u'ranks.rank': {
-            'Meta': {'object_name': 'Rank'},
-            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'roles.role': {
-            'Meta': {'object_name': 'Role'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'sessions.session': {
-            'Meta': {'object_name': 'Session'},
-            'admin': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'crawler': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'data': ('django.db.models.fields.TextField', [], {'db_column': "'session_data'"}),
-            'hidden': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'id': ('django.db.models.fields.CharField', [], {'max_length': '42', 'primary_key': 'True'}),
-            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'last': ('django.db.models.fields.DateTimeField', [], {}),
-            'matched': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'rank': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sessions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['ranks.Rank']"}),
-            'start': ('django.db.models.fields.DateTimeField', [], {}),
-            'team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sessions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"})
-        },
-        u'sessions.token': {
-            'Meta': {'object_name': 'Token'},
-            'accessed': ('django.db.models.fields.DateTimeField', [], {}),
-            'created': ('django.db.models.fields.DateTimeField', [], {}),
-            'id': ('django.db.models.fields.CharField', [], {'max_length': '42', 'primary_key': 'True'}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'signin_tokens'", 'to': u"orm['users.User']"})
-        },
-        u'users.user': {
-            'Meta': {'object_name': 'User'},
-            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'allow_pms': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
-            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
-            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
-            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
-            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ranks.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['roles.Role']", 'symmetrical': 'False'}),
-            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
-            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        }
-    }
-
-    complete_apps = ['sessions']

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


+ 0 - 0
misago/settings/__init__.py


+ 0 - 7
misago/settings/context_processors.py

@@ -1,7 +0,0 @@
-def settings(request):
-    try:
-        return {
-            'settings' : request.settings,
-        }
-    except AttributeError:
-        return {}

+ 0 - 146
misago/settings/fixtures.py

@@ -1,146 +0,0 @@
-import base64
-from misago.settings.models import Group, Setting
-from misago.utils import ugettext_lazy as _
-from misago.utils import get_msgid
-try:
-    import cPickle as pickle
-except ImportError:
-    import pickle
-
-settings_fixture = (
-   # Basic options
-   ('basic', {
-        'name': _("Basic Settings"),
-        'settings': (
-            ('board_name', {
-                'value':        "Misago",
-                'type':         "string",
-                'input':        "text",
-                'separator':    _("Board Name"),
-                'name':         _("Board Name"),
-            }),
-            ('board_header', {
-                'type':         "string",
-                'input':        "text",
-                'name':         _("Board Header"),
-                'description':  _("Some themes allow you to define text in board header. Leave empty to use Board Name instead."),
-            }),
-            ('board_header_postscript', {
-                'value':        "Work in progress",
-                'type':         "string",
-                'input':        "text",
-                'name':         _("Board Header Postscript"),
-                'description':  _("Additional text displayed in some themes board header after board name."),
-            }),
-            ('board_index_title', {
-                'type':         "string",
-                'input':        "text",
-                'separator':    _("Board Index"),
-                'name':         _("Board Index Title"),
-                'description':  _("If you want to, you can replace page title content on Board Index with custom one."),
-            }),
-            ('board_index_meta', {
-                'type':         "string",
-                'input':        "text",
-                'name':         _("Board Index Meta-Description"),
-                'description':  _("Meta-Description used to describe your board's index page."),
-            }),
-            ('board_credits', {
-                'type':         "string",
-                'input':        "textarea",
-                'separator':    _("Board Footer"),
-                'name':         _("Custom Credit"),
-                'description':  _("Custom Credit to display in board footer above software and theme copyright information. You can use HTML."),
-            }),
-            ('email_footnote', {
-                'type':         "string",
-                'input':        "textarea",
-                'separator':    _("Board E-Mails"),
-                'name':         _("Custom Footnote in HTML E-mails"),
-                'description':  _("Custom Footnote to display in HTML e-mail messages sent by board."),
-            }),
-            ('email_footnote_plain', {
-                'type':         "string",
-                'input':        "textarea",
-                'name':         _("Custom Footnote in plain text E-mails"),
-                'description':  _("Custom Footnote to display in plain text e-mail messages sent by board."),
-            }),
-        ),
-   }),
-)
-
-
-def load_settings_group_fixture(group, fixture):
-    model_group = Group(
-                        key=group,
-                        name=get_msgid(fixture['name']),
-                        description=get_msgid(fixture.get('description'))
-                        )
-    model_group.save(force_insert=True)
-    fixture = fixture.get('settings', ())
-    position = 0
-    for setting in fixture:
-        value = setting[1].get('value')
-        value_default = setting[1].get('default')
-        # Convert boolean True and False to 1 and 0, otherwhise it wont work
-        if setting[1].get('type') == 'boolean':
-            value = 1 if value else 0
-            value_default = 1 if value_default else 0
-        # Convert array value to string
-        if setting[1].get('type') == 'array':
-            value = ','.join(value) if value else ''
-            value_default = ','.join(value_default) if value_default else ''
-        # Store setting in database
-        model_setting = Setting(
-                                setting=setting[0],
-                                group=model_group,
-                                value=value,
-                                value_default=value_default,
-                                type=setting[1].get('type'),
-                                input=setting[1].get('input'),
-                                extra=base64.encodestring(pickle.dumps(setting[1].get('extra', {}), pickle.HIGHEST_PROTOCOL)),
-                                position=position,
-                                separator=get_msgid(setting[1].get('separator')),
-                                name=get_msgid(setting[1].get('name')),
-                                description=get_msgid(setting[1].get('description')),
-                            )
-        model_setting.save(force_insert=True)
-        position += 1
-
-
-def update_settings_group_fixture(group, fixture):
-    try:
-        model_group = Group.objects.get(key=group)
-        settings = {}
-        for setting in model_group.setting_set.all():
-            settings[setting.pk] = setting.value
-        model_group.delete()
-        load_settings_group_fixture(group, fixture)
-
-        for setting in settings:
-            try:
-                new_setting = Setting.objects.get(pk=setting)
-                new_setting.value = settings[setting]
-                new_setting.save(force_update=True)
-            except Setting.DoesNotExist:
-                pass
-    except Group.DoesNotExist:
-        load_settings_group_fixture(group, fixture)
-
-
-def load_settings_fixture(fixture):
-    for group in fixture:
-        load_settings_group_fixture(group[0], group[1])
-
-
-def update_settings_fixture(fixture):
-    for group in fixture:
-        update_settings_group_fixture(group[0], group[1])
-
-
-def load_fixtures():
-    load_settings_fixture(settings_fixture)
-
-
-def update_fixtures():
-    update_settings_fixture(settings_fixture)

+ 0 - 5
misago/settings/middleware.py

@@ -1,5 +0,0 @@
-from misago.settings.settings import Settings
-
-class SettingsMiddleware(object):
-    def process_request(self, request):
-        request.settings = Settings()

+ 0 - 69
misago/settings/migrations/0001_initial.py

@@ -1,69 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Group'
-        db.create_table(u'settings_group', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('key', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)),
-            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('description', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-        ))
-        db.send_create_signal(u'settings', ['Group'])
-
-        # Adding model 'Setting'
-        db.create_table(u'settings_setting', (
-            ('setting', self.gf('django.db.models.fields.CharField')(max_length=255, primary_key=True)),
-            ('group', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['settings.Group'], to_field='key')),
-            ('value', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('value_default', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('type', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('input', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('extra', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('position', self.gf('django.db.models.fields.IntegerField')(default=0)),
-            ('separator', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('description', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-        ))
-        db.send_create_signal(u'settings', ['Setting'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Group'
-        db.delete_table(u'settings_group')
-
-        # Deleting model 'Setting'
-        db.delete_table(u'settings_setting')
-
-
-    models = {
-        u'settings.group': {
-            'Meta': {'object_name': 'Group'},
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
-        },
-        u'settings.setting': {
-            'Meta': {'object_name': 'Setting'},
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'extra': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'group': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['settings.Group']", 'to_field': "'key'"}),
-            'input': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'position': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'separator': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'setting': ('django.db.models.fields.CharField', [], {'max_length': '255', 'primary_key': 'True'}),
-            'type': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'value_default': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
-        }
-    }
-
-    complete_apps = ['settings']

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


+ 36 - 76
misago/settings_base.py

@@ -75,16 +75,8 @@ TEMPLATE_CONTEXT_PROCESSORS = (
     'django.core.context_processors.static',
     'django.core.context_processors.tz',
     'django.contrib.messages.context_processors.messages',
-    'misago.context_processors.core',
-    'misago.admin.context_processors.admin',
-    'misago.banning.context_processors.banning',
-    'misago.messages.context_processors.messages',
-    'misago.monitor.context_processors.monitor',
-    'misago.settings.context_processors.settings',
-    'misago.bruteforce.context_processors.is_jammed',
-    'misago.csrf.context_processors.csrf',
-    'misago.users.context_processors.user',
-    'misago.acl.context_processors.acl',
+    'misago.context_processors.common',
+    'misago.context_processors.admin',
 )
 
 # Jinja2 Template Extensions
@@ -94,54 +86,57 @@ JINJA2_EXTENSIONS = (
 
 # List of application middlewares
 MIDDLEWARE_CLASSES = (
-    'misago.stopwatch.middleware.StopwatchMiddleware',
+    'misago.middleware.stopwatch.StopwatchMiddleware',
+    'misago.middleware.heartbeat.HeartbeatMiddleware',
     'debug_toolbar.middleware.DebugToolbarMiddleware',
-    'misago.heartbeat.middleware.HeartbeatMiddleware',
-    'misago.cookie_jar.middleware.CookieJarMiddleware',
-    'misago.settings.middleware.SettingsMiddleware',
-    'misago.monitor.middleware.MonitorMiddleware',
-    'misago.themes.middleware.ThemeMiddleware',
-    'misago.firewalls.middleware.FirewallMiddleware',
-    'misago.crawlers.middleware.DetectCrawlerMiddleware',
-    'misago.sessions.middleware.SessionMiddleware',
-    'misago.bruteforce.middleware.JamMiddleware',
-    'misago.csrf.middleware.CSRFMiddleware',
-    'misago.banning.middleware.BanningMiddleware',
-    'misago.messages.middleware.MessagesMiddleware',
-    'misago.users.middleware.UserMiddleware',
-    'misago.acl.middleware.ACLMiddleware',
+    'misago.middleware.cookiejar.CookieJarMiddleware',
+    'misago.middleware.settings.SettingsMiddleware',
+    'misago.middleware.monitor.MonitorMiddleware',
+    'misago.middleware.theme.ThemeMiddleware',
+    'misago.middleware.firewalls.FirewallMiddleware',
+    'misago.middleware.crawlers.DetectCrawlerMiddleware',
+    'misago.middleware.session.SessionMiddleware',
+    'misago.middleware.bruteforce.JamMiddleware',
+    'misago.middleware.csrf.CSRFMiddleware',
+    'misago.middleware.banning.BanningMiddleware',
+    'misago.middleware.messages.MessagesMiddleware',
+    'misago.middleware.user.UserMiddleware',
+    'misago.middleware.acl.ACLMiddleware',
+    'misago.middleware.privatethreads.PrivateThreadsMiddleware',
     'django.middleware.common.CommonMiddleware',
 )
 
 # List of application permission providers
 PERMISSION_PROVIDERS = (
-    'misago.usercp.acl',
-    'misago.users.acl',
-    'misago.admin.acl',
-    'misago.forums.acl',
-    'misago.threads.acl',
+    'misago.acl.permissions.usercp',
+    'misago.acl.permissions.users',
+    'misago.acl.permissions.forums',
+    'misago.acl.permissions.threads',
+    'misago.acl.permissions.privatethreads',
+    'misago.acl.permissions.special',
 )
 
 # List of UserCP extensions
 USERCP_EXTENSIONS = (
-    'misago.usercp.options',
-    'misago.usercp.avatar',
-    'misago.usercp.signature',
-    'misago.usercp.credentials',
-    'misago.usercp.username',
+    'misago.apps.usercp.options',
+    'misago.apps.usercp.avatar',
+    'misago.apps.usercp.signature',
+    'misago.apps.usercp.credentials',
+    'misago.apps.usercp.username',
 )
 
 # List of User Profile extensions
 PROFILE_EXTENSIONS = (
-    'misago.profiles.posts',
-    'misago.profiles.threads',
-    'misago.profiles.follows',
-    'misago.profiles.followers',
-    'misago.profiles.details',
+    'misago.apps.profiles.posts',
+    'misago.apps.profiles.threads',
+    'misago.apps.profiles.follows',
+    'misago.apps.profiles.followers',
+    'misago.apps.profiles.details',
 )
 
 # List of Markdown Extensions
 MARKDOWN_EXTENSIONS = (
+    'misago.markdown.extensions.strikethrough.StrikethroughExtension',
     'misago.markdown.extensions.quotes.QuoteTitlesExtension',
     'misago.markdown.extensions.mentions.MentionsExtension',
     'misago.markdown.extensions.magiclinks.MagicLinksExtension',
@@ -159,42 +154,7 @@ INSTALLED_APPS = (
     'django.contrib.humanize',
     'mptt', # Modified Pre-order Tree Transversal - allows us to nest forums 
     'debug_toolbar', # Debug toolbar
-    'misago.acl', # ACL Builder and dehydrator
-    'misago.settings', # Database level application configuration
-    'misago.monitor', # Forum statistics monitor
-    'misago.utils', # Utility classes
-    'misago.tos', # Terms of Service AKA Guidelines
-    # Applications with dependencies
-    'misago.banning', # Banning and blacklisting users
-    'misago.crawlers', # Web crawlers handling
-    'misago.cookie_jar', # Cookies helper
-    'misago.captcha', # Web crawlers handling
-    'misago.forums', # Forums, threads and posts
-    'misago.messages', # Messages and Flashes
-    'misago.newsletters', # Send newsletters to members from Admin
-    'misago.stats', # Admin statistics generator
-    'misago.sessions', # Sessions
-    'misago.authn', # User authentication
-    'misago.bruteforce', # Brute-Force protection
-    'misago.csrf', # Cross Site Request Forgery protection
-    'misago.setup', # Installation/update tool
-    'misago.template', # Templates extensions
-    'misago.themes', # Themes
-    'misago.users', # Users foundation
-    'misago.alerts', # Users Notifications
-    'misago.team', # Forum Team List
-    'misago.prune', # Prune Users
-    'misago.ranks', # User Ranks
-    'misago.roles', # User Roles
-    'misago.forumroles', # Forum Roles
-    'misago.usercp', # User Control Panel
-    'misago.profiles', # User Profiles
-    'misago.register', # Register New Users
-    'misago.activation', # Activate inactive User or resend activation e-mail
-    'misago.resetpswd', # Reset User Password
-    'misago.threads', # Threads and Posts
-    'misago.readstracker', # Forums and Threads reads tracker
-    'misago.watcher', # Observe threads
+    'misago', # Misago Forum App
 )
 
 # Stopwatch target file

+ 0 - 0
misago/setup/__init__.py


+ 0 - 34
misago/setup/fixtures.py

@@ -1,34 +0,0 @@
-from django.utils.importlib import import_module
-
-def load_app_fixtures(app):
-    """
-    See if application has fixtures module defining load_fixtures function
-    If it does, execute that function
-    """
-    app += '.fixtures'
-    try:
-        fixture = import_module(app)
-        fixture.load_fixtures()
-        return True
-    except (ImportError, AttributeError):
-        return False
-    except Exception as e:
-        print 'Could not load fixtures from %s:\n%s' % (app, e)
-        return False
-
-
-def update_app_fixtures(app):
-    """
-    See if application has fixtures module defining update_fixtures function
-    If it does, execute that function
-    """
-    app += '.fixtures'
-    try:
-        fixture = import_module(app)
-        fixture.update_fixtures()
-        return True
-    except (ImportError, AttributeError):
-        return False
-    except Exception as e:
-        print 'Could not update fixtures from %s:\n%s' % (app, e)
-        return False

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


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


+ 0 - 47
misago/setup/management/commands/initdata.py

@@ -1,47 +0,0 @@
-from django.conf import settings
-from django.core.management.base import BaseCommand, CommandError
-from django.db import (connections, router, transaction, DEFAULT_DB_ALIAS,
-      IntegrityError, DatabaseError)
-from django.utils import timezone
-from misago.setup.fixtures import load_app_fixtures, update_app_fixtures
-from misago.setup.models import Fixture
-from optparse import make_option
-
-class Command(BaseCommand):
-    """
-    Loads Misago fixtures
-    """
-    help = 'Load Misago fixtures'
-    option_list = BaseCommand.option_list + (
-        make_option('--quiet',
-            action='store_true',
-            dest='quiet',
-            default=False,
-            help='Dont display output from this message'),
-        )
-    
-    def handle(self, *args, **options):
-        if not options['quiet']:
-            self.stdout.write('\nLoading data from fixtures...')
-            
-        fixture_data = {}
-        for fixture in Fixture.objects.all():
-            fixture_data[fixture.app_name] = fixture
-        loaded = 0
-        updated = 0
-        
-        for app in settings.INSTALLED_APPS_COMPLETE:
-            if app in fixture_data:
-                if update_app_fixtures(app):
-                    updated += 1
-                    if not options['quiet']:
-                        self.stdout.write('Updating fixtures from %s' % app)
-            else:
-                if load_app_fixtures(app):
-                    loaded += 1
-                    Fixture.objects.create(app_name=app)
-                    if not options['quiet']:
-                        self.stdout.write('Loading fixtures from %s' % app)
-        
-        if not options['quiet']:
-            self.stdout.write('\nLoaded %s fixtures and updated %s fixtures.\n' % (loaded, updated))

+ 0 - 15
misago/setup/management/commands/initmisago.py

@@ -1,15 +0,0 @@
-from django.core.management import call_command
-from django.core.management.base import BaseCommand, CommandError
-
-class Command(BaseCommand):
-    """
-    Builds Misago database from scratch
-    """
-    help = 'Install Misago to database'
-    
-    def handle(self, *args, **options):
-        self.stdout.write('\nInstalling Misago to database...')
-        call_command('syncdb')
-        call_command('migrate')
-        call_command('initdata')
-        self.stdout.write('\nInstallation complete! Don\'t forget to run adduser to create first admin!\n')

+ 0 - 32
misago/setup/migrations/0001_initial.py

@@ -1,32 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Fixture'
-        db.create_table(u'setup_fixture', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('app_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-        ))
-        db.send_create_signal(u'setup', ['Fixture'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Fixture'
-        db.delete_table(u'setup_fixture')
-
-
-    models = {
-        u'setup.fixture': {
-            'Meta': {'object_name': 'Fixture'},
-            'app_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
-        }
-    }
-
-    complete_apps = ['setup']

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


+ 0 - 4
misago/setup/models.py

@@ -1,4 +0,0 @@
-from django.db import models
-
-class Fixture(models.Model):
-    app_name = models.CharField(max_length=255)

+ 7 - 3
misago/threads/signals.py → misago/signals.py

@@ -1,6 +1,10 @@
 import django.dispatch
 
-move_thread = django.dispatch.Signal(providing_args=["move_to"])
-move_post = django.dispatch.Signal(providing_args=["move_to"])
+delete_forum_content = django.dispatch.Signal()
+delete_user_content = django.dispatch.Signal()
+merge_post = django.dispatch.Signal(providing_args=["new_post"])
 merge_thread = django.dispatch.Signal(providing_args=["new_thread", "merge"])
-merge_post = django.dispatch.Signal(providing_args=["new_post"])
+move_forum_content = django.dispatch.Signal(providing_args=["move_to"])
+move_post = django.dispatch.Signal(providing_args=["move_to"])
+move_thread = django.dispatch.Signal(providing_args=["move_to"])
+rename_user = django.dispatch.Signal()

+ 0 - 0
misago/stats/__init__.py


+ 0 - 0
misago/stopwatch/__init__.py → misago/stopwatch.py


+ 0 - 0
misago/team/__init__.py


+ 0 - 0
misago/template/__init__.py


+ 0 - 0
misago/template/templatetags/__init__.py


+ 0 - 0
misago/readstracker/management/commands/__init__.py → misago/templatetags/__init__.py


+ 19 - 0
misago/templatetags/datetime.py

@@ -0,0 +1,19 @@
+from coffin.template import Library
+from misago.utils.datesformats import date, reldate, reltimesince
+
+register = Library()
+
+
+@register.filter(name='date')
+def date_filter(val, arg=""):
+    return date(val, arg)
+
+
+@register.filter(name='reldate')
+def reldate_filter(val, arg=""):
+    return reldate(val, arg)
+
+
+@register.filter(name='reltimesince')
+def reltimesince_filter(val, arg=""):
+    return reltimesince(val, arg)

+ 34 - 0
misago/templatetags/django2jinja.py

@@ -0,0 +1,34 @@
+import math
+import urllib
+from coffin.template import Library
+from django.conf import settings
+from misago.utils.strings import slugify
+
+register = Library()
+
+
+@register.object(name='widthratio')
+def widthratio(min=0, max=100, range=100):
+    return int(math.ceil(float(float(min) / float(max) * int(range))))
+
+
+@register.object(name='query')
+def query_string(**kwargs):
+    query = urllib.urlencode(kwargs)
+    return '?%s' % (query if kwargs else '')
+
+
+@register.filter(name='low')
+def low(value):
+    if not value:
+        return u''
+    try:
+        rest = value[1:]
+    except IndexError:
+        rest = ''
+    return '%s%s' % (unicode(value[0]).lower(), rest)
+
+
+@register.filter(name="slugify")
+def slugify_function(format_string):
+    return slugify(format_string)

+ 38 - 0
misago/templatetags/markdown.py

@@ -0,0 +1,38 @@
+import markdown
+from coffin.template import Library
+from django.conf import settings
+import misago.markdown
+
+register = Library()
+
+
+@register.filter(name='markdown')
+def parse_markdown(value, format=None):
+    if not format:
+        format = settings.OUTPUT_FORMAT
+    return markdown.markdown(value, safe_mode='escape', output_format=format).strip()
+
+
+@register.filter(name='markdown_short')
+def short_markdown(value, length=300):
+    value = misago.markdown.clear_markdown(value)
+
+    if len(value) <= length:
+        return ' '.join(value.splitlines())
+
+    value = ' '.join(value.splitlines())
+    value = value[0:length]
+
+    while value[-1] != ' ':
+        value = value[0:-1]
+
+    value = value.strip()
+    if value[-3:3] != '...':
+        value = '%s...' % value
+
+    return value
+
+
+@register.filter(name='markdown_final')
+def finalize_markdown(value):
+    return misago.markdown.finalize_markdown(value)

+ 17 - 0
misago/templatetags/utils.py

@@ -0,0 +1,17 @@
+from coffin.template import Library
+from misago.utils.strings import short_string
+
+register = Library()
+
+
+@register.object(name='intersect')
+def intersect(list_a, list_b):
+    for i in list_a:
+        if i in list_b:
+            return True
+    return False
+
+
+@register.filter(name='short_string')
+def make_short(string, length=16):
+    return short_string(string, length)

+ 1 - 0
misago/tests/__init__.py

@@ -0,0 +1 @@
+from misago.tests.testuseradd import UserAddTestCase

+ 56 - 0
misago/tests/testuseradd.py

@@ -0,0 +1,56 @@
+from django.core.exceptions import ValidationError
+from django.core.management import call_command
+from django.test import TestCase
+from misago.models import User, Rank, Role
+from misago.monitor import Monitor
+
+class UserAddTestCase(TestCase):
+    def setUp(self):
+        call_command('startmisago', quiet=True)
+
+    def test_user_from_model(self):
+        """Test User.objects.create_user"""
+
+        user_a = User.objects.create_user('Lemmiwinks', 'lemm@sp.com', '123pass')
+
+        monitor = Monitor()
+        self.assertEqual(int(monitor['users']), 1)
+        self.assertEqual(int(monitor['users_inactive']), 0)
+        self.assertEqual(int(monitor['last_user']), user_a.pk)
+        self.assertEqual(monitor['last_user_name'], user_a.username)
+        self.assertEqual(monitor['last_user_slug'], user_a.username_slug)
+
+        user_b = User.objects.create_user('InactiveTest', 'lemsm@sp.com', '123pass', activation=User.ACTIVATION_USER)
+
+        monitor = Monitor()
+        self.assertEqual(int(monitor['users']), 1)
+        self.assertEqual(int(monitor['users_inactive']), 1)
+        self.assertEqual(int(monitor['last_user']), user_a.pk)
+        self.assertEqual(monitor['last_user_name'], user_a.username)
+        self.assertEqual(monitor['last_user_slug'], user_a.username_slug)
+
+        try:
+            user_c = User.objects.create_user('UsedMail', 'lemsm@sp.com', '123pass')
+            raise AssertionError("Created user account with taken e-mail address!")
+        except ValidationError:
+            pass
+
+        monitor = Monitor()
+        self.assertEqual(int(monitor['users']), 1)
+        self.assertEqual(int(monitor['users_inactive']), 1)
+        self.assertEqual(int(monitor['last_user']), user_a.pk)
+        self.assertEqual(monitor['last_user_name'], user_a.username)
+        self.assertEqual(monitor['last_user_slug'], user_a.username_slug)
+
+        try:
+            user_d = User.objects.create_user('InactiveTest', 'user@name.com', '123pass')
+            raise AssertionError("Created user account with taken username!")
+        except ValidationError:
+            pass
+
+        monitor = Monitor()
+        self.assertEqual(int(monitor['users']), 1)
+        self.assertEqual(int(monitor['users_inactive']), 1)
+        self.assertEqual(int(monitor['last_user']), user_a.pk)
+        self.assertEqual(monitor['last_user_name'], user_a.username)
+        self.assertEqual(monitor['last_user_slug'], user_a.username_slug)

+ 15 - 6
misago/themes/theme.py → misago/theme.py

@@ -1,5 +1,6 @@
 from django.conf import settings
-from coffin.shortcuts import render, render_to_response
+from coffin.shortcuts import render_to_response
+from coffin.template import dict_from_django_context
 from coffin.template.loader import get_template, select_template, render_to_string
 
 '''Monkeypatch Django to mimic Jinja2 behaviour'''
@@ -35,9 +36,6 @@ class Theme(object):
             prefixed += templates
             return prefixed
 
-    def render(self, request, *args, **kwargs):
-        return render(request, *args, **kwargs)
-
     def render_to_string(self, templates, *args, **kwargs):
         templates = self.prefix_templates(templates)
         return render_to_string(templates, *args, **kwargs)
@@ -46,9 +44,20 @@ class Theme(object):
         templates = self.prefix_templates(templates)
         return render_to_response(templates, *args, **kwargs)
 
+    def macro(self, templates, macro, dictionary={}, context_instance=None):
+        templates = self.prefix_templates(templates)
+        template = select_template(templates)
+        if context_instance:
+            context_instance.update(dictionary)
+        else:
+            context_instance = dictionary
+        context_instance = dict_from_django_context(context_instance)
+        _macro = getattr(template.make_module(context_instance), macro)
+        return unicode(_macro()).strip()
+
     def get_email_templates(self, template, contex={}):
-            email_type_plain = '_email/%s_plain.html' % template
-            email_type_html = '_email/%s_html.html' % template
+            email_type_plain = '_email/%s.txt' % template
+            email_type_html = '_email/%s.html' % template
             return (
                     select_template(('%s/%s' % (self._theme, email_type_plain[1:]), email_type_plain)),
                     select_template(('%s/%s' % (self._theme, email_type_html[1:]), email_type_html)),

+ 0 - 0
misago/themes/__init__.py


+ 0 - 34
misago/themes/migrations/0001_initial.py

@@ -1,34 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'ThemeAdjustment'
-        db.create_table(u'themes_themeadjustment', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('theme', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)),
-            ('useragents', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-        ))
-        db.send_create_signal(u'themes', ['ThemeAdjustment'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'ThemeAdjustment'
-        db.delete_table(u'themes_themeadjustment')
-
-
-    models = {
-        u'themes.themeadjustment': {
-            'Meta': {'object_name': 'ThemeAdjustment'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'theme': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
-            'useragents': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
-        }
-    }
-
-    complete_apps = ['themes']

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


+ 0 - 0
misago/threads/__init__.py


+ 0 - 226
misago/threads/forms.py

@@ -1,226 +0,0 @@
-from django import forms
-from django.conf import settings
-from django.utils.translation import ungettext, ugettext_lazy as _
-from misago.acl.utils import ACLError403, ACLError404
-from misago.forms import Form, ForumChoiceField
-from misago.forums.models import Forum
-from misago.threads.models import Thread
-from misago.utils import slugify
-from misago.utils.validators import validate_sluggable
-
-class ThreadNameMixin(object):
-    def clean_thread_name(self):
-        data = self.cleaned_data['thread_name']
-        slug = slugify(data)
-        if len(slug) < self.request.settings['thread_name_min']:
-            raise forms.ValidationError(ungettext(
-                                                  "Thread name must contain at least one alpha-numeric character.",
-                                                  "Thread name must contain at least %(count)d alpha-numeric characters.",
-                                                  self.request.settings['thread_name_min']
-                                                  ) % {'count': self.request.settings['thread_name_min']})
-        if len(data) > self.request.settings['thread_name_max']:
-            raise forms.ValidationError(ungettext(
-                                                  "Thread name cannot be longer than %(count)d character.",
-                                                  "Thread name cannot be longer than %(count)d characters.",
-                                                  self.request.settings['thread_name_max']
-                                                  ) % {'count': self.request.settings['thread_name_max']})
-        return data
-
-
-class PostForm(Form, ThreadNameMixin):
-    post = forms.CharField(widget=forms.Textarea)
-
-    def __init__(self, data=None, file=None, request=None, mode=None, *args, **kwargs):
-        self.mode = mode
-        super(PostForm, self).__init__(data, file, request=request, *args, **kwargs)
-
-    def finalize_form(self):
-        self.layout = [
-                       [
-                        None,
-                        [
-                         ('thread_name', {'label': _("Thread Name")}),
-                         ('edit_reason', {'label': _("Edit Reason")}),
-                         ('post', {'label': _("Post Content")}),
-                         ],
-                        ],
-                       ]
-
-        if self.mode in ['edit_thread', 'edit_post']:
-            self.fields['edit_reason'] = forms.CharField(max_length=255, required=False, help_text=_("Optional reason for changing this post."))
-        else:
-            del self.layout[0][1][1]
-
-        if self.mode not in ['edit_thread', 'new_thread']:
-            del self.layout[0][1][0]
-        else:
-            self.fields['thread_name'] = forms.CharField(
-                                                         max_length=self.request.settings['thread_name_max'],
-                                                         validators=[validate_sluggable(
-                                                                                        _("Thread name must contain at least one alpha-numeric character."),
-                                                                                        _("Thread name is too long. Try shorter name.")
-                                                                                        )])
-
-    def clean_post(self):
-        data = self.cleaned_data['post']
-        if len(data) < self.request.settings['post_length_min']:
-            raise forms.ValidationError(ungettext(
-                                                  "Post content cannot be empty.",
-                                                  "Post content cannot be shorter than %(count)d characters.",
-                                                  self.request.settings['post_length_min']
-                                                  ) % {'count': self.request.settings['post_length_min']})
-        return data
-
-
-
-class SplitThreadForm(Form, ThreadNameMixin):
-    def finalize_form(self):
-        self.layout = [
-                       [
-                        None,
-                        [
-                         ('thread_name', {'label': _("New Thread Name")}),
-                         ('thread_forum', {'label': _("New Thread Forum")}),
-                         ],
-                        ],
-                       ]
-
-        self.fields['thread_name'] = forms.CharField(
-                                                     max_length=self.request.settings['thread_name_max'],
-                                                     validators=[validate_sluggable(
-                                                                                    _("Thread name must contain at least one alpha-numeric character."),
-                                                                                    _("Thread name is too long. Try shorter name.")
-                                                                                    )])
-        self.fields['thread_forum'] = ForumChoiceField(queryset=Forum.tree.get(token='root').get_descendants().filter(pk__in=self.request.acl.forums.acl['can_browse']))
-
-    def clean_thread_forum(self):
-        new_forum = self.cleaned_data['thread_forum']
-        # Assert its forum and its not current forum
-        if new_forum.type != 'forum':
-            raise forms.ValidationError(_("This is not a forum."))
-        return new_forum
-
-
-class MovePostsForm(Form, ThreadNameMixin):
-    error_source = 'thread_url'
-
-    def __init__(self, data=None, request=None, thread=None, *args, **kwargs):
-        self.thread = thread
-        super(MovePostsForm, self).__init__(data, request=request, *args, **kwargs)
-
-    def finalize_form(self):
-        self.layout = [
-                       [
-                        None,
-                        [
-                         ('thread_url', {'label': _("New Thread Link"), 'help_text': _("To select new thread, simply copy and paste here its link.")}),
-                         ],
-                        ],
-                       ]
-
-        self.fields['thread_url'] = forms.CharField()
-
-    def clean_thread_url(self):
-        from django.core.urlresolvers import resolve
-        from django.http import Http404
-        thread_url = self.cleaned_data['thread_url']
-        try:
-            thread_url = thread_url[len(settings.BOARD_ADDRESS):]
-            match = resolve(thread_url)
-            thread = Thread.objects.get(pk=match.kwargs['thread'])
-            self.request.acl.threads.allow_thread_view(self.request.user, thread)
-            if thread.pk == self.thread.pk:
-                raise forms.ValidationError(_("New thread is same as current one."))
-            return thread
-        except (Http404, KeyError):
-            raise forms.ValidationError(_("This is not a correct thread URL."))
-        except (Thread.DoesNotExist, ACLError403, ACLError404):
-            raise forms.ValidationError(_("Thread could not be found."))
-
-
-class QuickReplyForm(Form):
-    post = forms.CharField(widget=forms.Textarea)
-
-
-class MoveThreadsForm(Form):
-    error_source = 'new_forum'
-
-    def __init__(self, data=None, request=None, forum=None, *args, **kwargs):
-        self.forum = forum
-        super(MoveThreadsForm, self).__init__(data, request=request, *args, **kwargs)
-
-    def finalize_form(self):
-        self.fields['new_forum'] = ForumChoiceField(queryset=Forum.tree.get(token='root').get_descendants().filter(pk__in=self.request.acl.forums.acl['can_browse']))
-        self.layout = [
-                       [
-                        None,
-                        [
-                         ('new_forum', {'label': _("Move Threads to"), 'help_text': _("Select forum you want to move threads to.")}),
-                         ],
-                        ],
-                       ]
-
-    def clean_new_forum(self):
-        new_forum = self.cleaned_data['new_forum']
-        # Assert its forum and its not current forum
-        if new_forum.type != 'forum':
-            raise forms.ValidationError(_("This is not forum."))
-        if new_forum.pk == self.forum.pk:
-            raise forms.ValidationError(_("New forum is same as current one."))
-        return new_forum
-
-
-class MergeThreadsForm(Form, ThreadNameMixin):
-    def __init__(self, data=None, request=None, threads=[], *args, **kwargs):
-        self.threads = threads
-        super(MergeThreadsForm, self).__init__(data, request=request, *args, **kwargs)
-
-    def finalize_form(self):
-        self.fields['new_forum'] = ForumChoiceField(queryset=Forum.tree.get(token='root').get_descendants().filter(pk__in=self.request.acl.forums.acl['can_browse']), initial=self.threads[0].forum)
-        self.fields['thread_name'] = forms.CharField(
-                                                     max_length=self.request.settings['thread_name_max'],
-                                                     initial=self.threads[0].name,
-                                                     validators=[validate_sluggable(
-                                                                                    _("Thread name must contain at least one alpha-numeric character."),
-                                                                                    _("Thread name is too long. Try shorter name.")
-                                                                                    )])
-        self.layout = [
-                       [
-                        None,
-                        [
-                         ('thread_name', {'label': _("Thread Name"), 'help_text': _("Name of new thread that will be created as result of merge.")}),
-                         ('new_forum', {'label': _("Thread Forum"), 'help_text': _("Select forum you want to put new thread in.")}),
-                         ],
-                        ],
-                       [
-                        _("Merge Order"),
-                        [
-                         ],
-                        ],
-                       ]
-
-        choices = []
-        for i, thread in enumerate(self.threads):
-            choices.append((str(i), i + 1))
-        for i, thread in enumerate(self.threads):
-            self.fields['thread_%s' % thread.pk] = forms.ChoiceField(choices=choices, initial=str(i))
-            self.layout[1][1].append(('thread_%s' % thread.pk, {'label': thread.name}))
-
-    def clean_new_forum(self):
-        new_forum = self.cleaned_data['new_forum']
-        # Assert its forum
-        if new_forum.type != 'forum':
-            raise forms.ValidationError(_("This is not forum."))
-        return new_forum
-
-    def clean(self):
-        cleaned_data = super(MergeThreadsForm, self).clean()
-        self.merge_order = {}
-        lookback = []
-        for thread in self.threads:
-            order = int(cleaned_data['thread_%s' % thread.pk])
-            if order in lookback:
-                raise forms.ValidationError(_("One or more threads have same position in merge order."))
-            lookback.append(order)
-            self.merge_order[order] = thread
-        return cleaned_data

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


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


+ 0 - 382
misago/threads/migrations/0001_initial.py

@@ -1,382 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'Thread'
-        db.create_table(u'threads_thread', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['forums.Forum'])),
-            ('weight', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('slug', self.gf('django.db.models.fields.SlugField')(max_length=255)),
-            ('replies', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('replies_reported', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('replies_moderated', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('replies_deleted', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('merges', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('score', self.gf('django.db.models.fields.PositiveIntegerField')(default=30)),
-            ('upvotes', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('downvotes', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('start', self.gf('django.db.models.fields.DateTimeField')()),
-            ('start_post', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['threads.Post'])),
-            ('start_poster', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['users.User'], null=True, on_delete=models.SET_NULL, blank=True)),
-            ('start_poster_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('start_poster_slug', self.gf('django.db.models.fields.SlugField')(max_length=255)),
-            ('start_poster_style', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('last', self.gf('django.db.models.fields.DateTimeField')()),
-            ('last_post', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['threads.Post'])),
-            ('last_poster', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['users.User'])),
-            ('last_poster_name', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('last_poster_slug', self.gf('django.db.models.fields.SlugField')(max_length=255, null=True, blank=True)),
-            ('last_poster_style', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('moderated', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('deleted', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('closed', self.gf('django.db.models.fields.BooleanField')(default=False)),
-        ))
-        db.send_create_signal(u'threads', ['Thread'])
-
-        # Adding model 'Post'
-        db.create_table(u'threads_post', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['forums.Forum'])),
-            ('thread', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['threads.Thread'])),
-            ('merge', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['users.User'], null=True, on_delete=models.SET_NULL, blank=True)),
-            ('user_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
-            ('agent', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('post', self.gf('django.db.models.fields.TextField')()),
-            ('post_preparsed', self.gf('django.db.models.fields.TextField')()),
-            ('upvotes', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('downvotes', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('checkpoints', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('date', self.gf('django.db.models.fields.DateTimeField')()),
-            ('edits', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('edit_date', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
-            ('edit_reason', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('edit_user', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='+', null=True, on_delete=models.SET_NULL, to=orm['users.User'])),
-            ('edit_user_name', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('edit_user_slug', self.gf('django.db.models.fields.SlugField')(max_length=255, null=True, blank=True)),
-            ('reported', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('moderated', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('deleted', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('protected', self.gf('django.db.models.fields.BooleanField')(default=False)),
-        ))
-        db.send_create_signal(u'threads', ['Post'])
-
-        # Adding M2M table for field mentions on 'Post'
-        db.create_table(u'threads_post_mentions', (
-            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
-            ('post', models.ForeignKey(orm[u'threads.post'], null=False)),
-            ('user', models.ForeignKey(orm[u'users.user'], null=False))
-        ))
-        db.create_unique(u'threads_post_mentions', ['post_id', 'user_id'])
-
-        # Adding model 'Karma'
-        db.create_table(u'threads_karma', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['forums.Forum'])),
-            ('thread', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['threads.Thread'])),
-            ('post', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['threads.Post'])),
-            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['users.User'], null=True, on_delete=models.SET_NULL, blank=True)),
-            ('user_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('user_slug', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('date', self.gf('django.db.models.fields.DateTimeField')()),
-            ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
-            ('agent', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('score', self.gf('django.db.models.fields.IntegerField')(default=0)),
-        ))
-        db.send_create_signal(u'threads', ['Karma'])
-
-        # Adding model 'Change'
-        db.create_table(u'threads_change', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['forums.Forum'])),
-            ('thread', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['threads.Thread'])),
-            ('post', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['threads.Post'])),
-            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['users.User'], null=True, on_delete=models.SET_NULL, blank=True)),
-            ('user_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('user_slug', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('date', self.gf('django.db.models.fields.DateTimeField')()),
-            ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
-            ('agent', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('reason', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('thread_name_new', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('thread_name_old', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('post_content', self.gf('django.db.models.fields.TextField')()),
-            ('size', self.gf('django.db.models.fields.IntegerField')(default=0)),
-            ('change', self.gf('django.db.models.fields.IntegerField')(default=0)),
-        ))
-        db.send_create_signal(u'threads', ['Change'])
-
-        # Adding model 'Checkpoint'
-        db.create_table(u'threads_checkpoint', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['forums.Forum'])),
-            ('thread', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['threads.Thread'])),
-            ('post', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['threads.Post'])),
-            ('action', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['users.User'], null=True, on_delete=models.SET_NULL, blank=True)),
-            ('user_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('user_slug', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('date', self.gf('django.db.models.fields.DateTimeField')()),
-            ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
-            ('agent', self.gf('django.db.models.fields.CharField')(max_length=255)),
-        ))
-        db.send_create_signal(u'threads', ['Checkpoint'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'Thread'
-        db.delete_table(u'threads_thread')
-
-        # Deleting model 'Post'
-        db.delete_table(u'threads_post')
-
-        # Removing M2M table for field mentions on 'Post'
-        db.delete_table('threads_post_mentions')
-
-        # Deleting model 'Karma'
-        db.delete_table(u'threads_karma')
-
-        # Deleting model 'Change'
-        db.delete_table(u'threads_change')
-
-        # Deleting model 'Checkpoint'
-        db.delete_table(u'threads_checkpoint')
-
-
-    models = {
-        u'forums.forum': {
-            'Meta': {'object_name': 'Forum'},
-            'attrs': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'description_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_thread': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Thread']"}),
-            'last_thread_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_thread_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_thread_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'parent': ('mptt.fields.TreeForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': u"orm['forums.Forum']"}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'posts_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'prune_last': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'prune_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'redirect': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'redirects': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'redirects_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'show_details': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'type': ('django.db.models.fields.CharField', [], {'max_length': '12'})
-        },
-        u'ranks.rank': {
-            'Meta': {'object_name': 'Rank'},
-            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'roles.role': {
-            'Meta': {'object_name': 'Role'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'threads.change': {
-            'Meta': {'object_name': 'Change'},
-            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'change': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'date': ('django.db.models.fields.DateTimeField', [], {}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'post': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Post']"}),
-            'post_content': ('django.db.models.fields.TextField', [], {}),
-            'reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'size': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Thread']"}),
-            'thread_name_new': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'thread_name_old': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
-        },
-        u'threads.checkpoint': {
-            'Meta': {'object_name': 'Checkpoint'},
-            'action': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'date': ('django.db.models.fields.DateTimeField', [], {}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'post': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Post']"}),
-            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Thread']"}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
-        },
-        u'threads.karma': {
-            'Meta': {'object_name': 'Karma'},
-            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'date': ('django.db.models.fields.DateTimeField', [], {}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'post': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Post']"}),
-            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Thread']"}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
-        },
-        u'threads.post': {
-            'Meta': {'object_name': 'Post'},
-            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'checkpoints': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'date': ('django.db.models.fields.DateTimeField', [], {}),
-            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'edit_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'edit_reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edit_user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'edit_user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edit_user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edits': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'mentions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'mention_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'merge': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'post': ('django.db.models.fields.TextField', [], {}),
-            'post_preparsed': ('django.db.models.fields.TextField', [], {}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'reported': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Thread']"}),
-            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
-        },
-        u'threads.thread': {
-            'Meta': {'object_name': 'Thread'},
-            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last': ('django.db.models.fields.DateTimeField', [], {}),
-            'last_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Post']"}),
-            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'merges': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'replies': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_deleted': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_moderated': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_reported': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'score': ('django.db.models.fields.PositiveIntegerField', [], {'default': '30'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'start': ('django.db.models.fields.DateTimeField', [], {}),
-            'start_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Post']"}),
-            'start_poster': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'start_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'start_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'start_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'type': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'weight': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        },
-        u'users.user': {
-            'Meta': {'object_name': 'User'},
-            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'allow_pms': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
-            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
-            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
-            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
-            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ranks.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['roles.Role']", 'symmetrical': 'False'}),
-            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
-            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        }
-    }
-
-    complete_apps = ['threads']

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


+ 0 - 392
misago/threads/models.py

@@ -1,392 +0,0 @@
-from datetime import timedelta
-from django.conf import settings
-from django.db import models
-from django.db.models import F
-from django.utils import timezone
-from django.utils.translation import ugettext_lazy as _
-from misago.forums.signals import move_forum_content
-from misago.threads.signals import move_thread, merge_thread, move_post, merge_post
-from misago.users.signals import delete_user_content, rename_user
-from misago.utils import slugify, ugettext_lazy
-from misago.readstracker.models import ForumRecord, ThreadRecord
-from misago.watcher.models import ThreadWatch
-
-class ThreadManager(models.Manager):
-    def filter_stats(self, start, end):
-        return self.filter(start__gte=start).filter(start__lte=end)
-
-    def with_reads(self, queryset, user):
-        threads = []
-        threads_dict = {}
-        forum_reads = {}
-        cutoff = timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)
-
-        if user.is_authenticated() and user.join_date > cutoff:
-            cutoff = user.join_date
-            for row in ForumRecord.objects.filter(user=user).values('forum_id', 'cleared'):
-                forum_reads[row['forum_id']] = row['cleared']
-
-        for thread in queryset:
-            thread.is_read = True
-            if user.is_authenticated() and thread.last > cutoff:
-                try:
-                    thread.is_read = thread.last <= forum_reads[thread.forum_id]
-                except KeyError:
-                    pass
-
-            threads.append(thread)
-            threads_dict[thread.pk] = thread
-
-        if user.is_authenticated():
-            for read in ThreadRecord.objects.filter(user=user).filter(thread__in=threads_dict.keys()):
-                try:
-                    threads_dict[read.thread_id].is_read = (threads_dict[read.thread_id].last <= cutoff or 
-                                                            threads_dict[read.thread_id].last <= read.updated or
-                                                            threads_dict[read.thread_id].last <= forum_reads[read.forum_id])
-                except KeyError:
-                    pass
-
-        return threads
-
-
-class Thread(models.Model):
-    forum = models.ForeignKey('forums.Forum')
-    weight = models.PositiveIntegerField(default=0)
-    name = models.CharField(max_length=255)
-    slug = models.SlugField(max_length=255)
-    replies = models.PositiveIntegerField(default=0)
-    replies_reported = models.PositiveIntegerField(default=0)
-    replies_moderated = models.PositiveIntegerField(default=0)
-    replies_deleted = models.PositiveIntegerField(default=0)
-    merges = models.PositiveIntegerField(default=0)
-    score = models.PositiveIntegerField(default=30)
-    upvotes = models.PositiveIntegerField(default=0)
-    downvotes = models.PositiveIntegerField(default=0)
-    start = models.DateTimeField()
-    start_post = models.ForeignKey('Post', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
-    start_poster = models.ForeignKey('users.User', null=True, blank=True, on_delete=models.SET_NULL)
-    start_poster_name = models.CharField(max_length=255)
-    start_poster_slug = models.SlugField(max_length=255)
-    start_poster_style = models.CharField(max_length=255, null=True, blank=True)
-    last = models.DateTimeField()
-    last_post = models.ForeignKey('Post', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
-    last_poster = models.ForeignKey('users.User', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
-    last_poster_name = models.CharField(max_length=255, null=True, blank=True)
-    last_poster_slug = models.SlugField(max_length=255, null=True, blank=True)
-    last_poster_style = models.CharField(max_length=255, null=True, blank=True)
-    moderated = models.BooleanField(default=False)
-    deleted = models.BooleanField(default=False)
-    closed = models.BooleanField(default=False)
-
-    objects = ThreadManager()
-
-    statistics_name = _('New Threads')
-
-    def get_date(self):
-        return self.start
-
-    def move_to(self, move_to):
-        move_thread.send(sender=self, move_to=move_to)
-        self.forum = move_to
-
-    def merge_with(self, thread, merge):
-        merge_thread.send(sender=self, new_thread=thread, merge=merge)
-
-    def sync(self):
-        # Counters
-        self.replies = self.post_set.filter(moderated=False).count() - 1
-        if self.replies < 0:
-            self.replies = 0
-        self.replies_reported = self.post_set.filter(reported=True).count()
-        self.replies_moderated = self.post_set.filter(moderated=True).count()
-        self.replies_deleted = self.post_set.filter(deleted=True).count()
-        # First post
-        start_post = self.post_set.order_by('merge', 'id')[0:][0]
-        self.start = start_post.date
-        self.start_post = start_post
-        self.start_poster = start_post.user
-        self.start_poster_name = start_post.user_name
-        self.start_poster_slug = slugify(start_post.user_name)
-        self.start_poster_style = start_post.user.rank.style if start_post.user else ''
-        self.upvotes = start_post.upvotes
-        self.downvotes = start_post.downvotes
-        # Last visible post
-        if self.replies > 0:
-            last_post = self.post_set.order_by('-merge', '-id').filter(moderated=False)[0:][0]
-        else:
-            last_post = start_post
-        self.last = last_post.date
-        self.last_post = last_post
-        self.last_poster = last_post.user
-        self.last_poster_name = last_post.user_name
-        self.last_poster_slug = slugify(last_post.user_name)
-        self.last_poster_style = last_post.user.rank.style if last_post.user else ''
-        # Flags
-        self.moderated = start_post.moderated
-        self.deleted = start_post.deleted
-        self.merges = last_post.merge
-        
-    def email_watchers(self, request, post):
-        from misago.acl.builder import get_acl
-        from misago.acl.utils import ACLError403, ACLError404
-        
-        for watch in ThreadWatch.objects.filter(thread=self).filter(email=True).filter(last_read__gte=self.previous_last):
-            user = watch.user
-            if user.pk != request.user.pk:
-                try:
-                    acl = get_acl(request, user)
-                    acl.forums.allow_forum_view(self.forum)
-                    acl.threads.allow_thread_view(user, self)
-                    acl.threads.allow_post_view(user, self, post)
-                    if not user.is_ignoring(request.user):
-                        user.email_user(
-                            request,
-                            'post_notification',
-                            _('New reply in thread "%(thread)s"') % {'thread': self.name},
-                            {'author': request.user, 'post': post, 'thread': self}
-                            )
-                except (ACLError403, ACLError404):
-                    pass
-
-
-class PostManager(models.Manager):
-    def filter_stats(self, start, end):
-        return self.filter(date__gte=start).filter(date__lte=end)
-
-
-class Post(models.Model):
-    forum = models.ForeignKey('forums.Forum')
-    thread = models.ForeignKey(Thread)
-    merge = models.PositiveIntegerField(default=0)
-    user = models.ForeignKey('users.User', null=True, blank=True, on_delete=models.SET_NULL)
-    user_name = models.CharField(max_length=255)
-    ip = models.GenericIPAddressField()
-    agent = models.CharField(max_length=255)
-    post = models.TextField()
-    post_preparsed = models.TextField()
-    upvotes = models.PositiveIntegerField(default=0)
-    downvotes = models.PositiveIntegerField(default=0)
-    mentions = models.ManyToManyField('users.User', related_name="mention_set")
-    checkpoints = models.BooleanField(default=False)
-    date = models.DateTimeField()
-    edits = models.PositiveIntegerField(default=0)
-    edit_date = models.DateTimeField(null=True, blank=True)
-    edit_reason = models.CharField(max_length=255, null=True, blank=True)
-    edit_user = models.ForeignKey('users.User', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
-    edit_user_name = models.CharField(max_length=255, null=True, blank=True)
-    edit_user_slug = models.SlugField(max_length=255, null=True, blank=True)
-    reported = models.BooleanField(default=False)
-    moderated = models.BooleanField(default=False)
-    deleted = models.BooleanField(default=False)
-    protected = models.BooleanField(default=False)
-
-    objects = PostManager()
-
-    statistics_name = _('New Posts')
-
-    def get_date(self):
-        return self.date
-
-    def move_to(self, thread):
-        move_post.send(sender=self, move_to=thread)
-        self.thread = thread
-        self.forum = thread.forum
-        
-    def merge_with(self, post):
-        post.post = '%s\n- - -\n%s' % (post.post, self.post)
-        merge_post.send(sender=self, new_post=post)
-
-    def set_checkpoint(self, request, action):
-        if request.user.is_authenticated():
-            self.checkpoints = True
-            self.checkpoint_set.create(
-                                       forum=self.forum,
-                                       thread=self.thread,
-                                       post=self,
-                                       action=action,
-                                       user=request.user,
-                                       user_name=request.user.username,
-                                       user_slug=request.user.username_slug,
-                                       date=timezone.now(),
-                                       ip=request.session.get_ip(request),
-                                       agent=request.META.get('HTTP_USER_AGENT'),
-                                       )
-            
-    def notify_mentioned(self, request, users):
-        from misago.acl.builder import get_acl
-        from misago.acl.utils import ACLError403, ACLError404
-        
-        mentioned = self.mentions.all()
-        for slug, user in users.items():
-            if user.pk != request.user.pk and user not in mentioned:
-                self.mentions.add(user)
-                try:                    
-                    acl = get_acl(request, user)
-                    acl.forums.allow_forum_view(self.forum)
-                    acl.threads.allow_thread_view(user, self.thread)
-                    acl.threads.allow_post_view(user, self.thread, self)
-                    if not user.is_ignoring(request.user):
-                        alert = user.alert(ugettext_lazy("%(username)s has mentioned you in his reply in thread %(thread)s").message)
-                        alert.profile('username', request.user)
-                        alert.post('thread', self.thread, self)
-                        alert.save_all()
-                except (ACLError403, ACLError404):
-                    pass
-
-
-class Karma(models.Model):
-    forum = models.ForeignKey('forums.Forum')
-    thread = models.ForeignKey(Thread)
-    post = models.ForeignKey(Post)
-    user = models.ForeignKey('users.User', null=True, blank=True, on_delete=models.SET_NULL)
-    user_name = models.CharField(max_length=255)
-    user_slug = models.CharField(max_length=255)
-    date = models.DateTimeField()
-    ip = models.GenericIPAddressField()
-    agent = models.CharField(max_length=255)
-    score = models.IntegerField(default=0)
-    
-
-class Change(models.Model):
-    forum = models.ForeignKey('forums.Forum')
-    thread = models.ForeignKey(Thread)
-    post = models.ForeignKey(Post)
-    user = models.ForeignKey('users.User', null=True, blank=True, on_delete=models.SET_NULL)
-    user_name = models.CharField(max_length=255)
-    user_slug = models.CharField(max_length=255)
-    date = models.DateTimeField()
-    ip = models.GenericIPAddressField()
-    agent = models.CharField(max_length=255)
-    reason = models.CharField(max_length=255, null=True, blank=True)
-    thread_name_new = models.CharField(max_length=255, null=True, blank=True)
-    thread_name_old = models.CharField(max_length=255, null=True, blank=True)
-    post_content = models.TextField()
-    size = models.IntegerField(default=0)
-    change = models.IntegerField(default=0)
-
-
-class Checkpoint(models.Model):
-    forum = models.ForeignKey('forums.Forum')
-    thread = models.ForeignKey(Thread)
-    post = models.ForeignKey(Post)
-    action = models.CharField(max_length=255)
-    user = models.ForeignKey('users.User', null=True, blank=True, on_delete=models.SET_NULL)
-    user_name = models.CharField(max_length=255)
-    user_slug = models.CharField(max_length=255)
-    date = models.DateTimeField()
-    ip = models.GenericIPAddressField()
-    agent = models.CharField(max_length=255)
-
-
-"""
-Signals
-"""
-def rename_user_handler(sender, **kwargs):
-    Thread.objects.filter(start_poster=sender).update(
-                                                     start_poster_name=sender.username,
-                                                     start_poster_slug=sender.username_slug,
-                                                     )
-    Thread.objects.filter(last_poster=sender).update(
-                                                     last_poster_name=sender.username,
-                                                     last_poster_slug=sender.username_slug,
-                                                     )
-    Post.objects.filter(user=sender).update(
-                                            user_name=sender.username,
-                                            )
-    Post.objects.filter(edit_user=sender).update(
-                                                 edit_user_name=sender.username,
-                                                 edit_user_slug=sender.username_slug,
-                                                 )
-    Karma.objects.filter(user=sender).update(
-                                             user_name=sender.username,
-                                             user_slug=sender.username_slug,
-                                             )
-    Change.objects.filter(user=sender).update(
-                                              user_name=sender.username,
-                                              user_slug=sender.username_slug,
-                                              )
-    Checkpoint.objects.filter(user=sender).update(
-                                                  user_name=sender.username,
-                                                  user_slug=sender.username_slug,
-                                                  )
-
-rename_user.connect(rename_user_handler, dispatch_uid="rename_user_threads")
-
-
-def delete_user_content_handler(sender, **kwargs):
-    for thread in Thread.objects.filter(start_poster=sender):
-        thread.delete()
-    threads = []
-    prev_posts = []
-    for post in sender.post_set.filter(checkpoints=True):
-        threads.append(post.thread_id)
-        prev_post = Post.objects.filter(thread=post.thread_id).exclude(merge__gt=post.merge).exclude(user=sender).order_by('merge', '-id')[:1][0]
-        post.checkpoint_set.update(post=prev_post)
-        if not prev_post.pk in prev_posts:
-            prev_posts.append(prev_post.pk)
-    sender.post_set.all().delete()
-    Post.objects.filter(id__in=prev_posts).update(checkpoints=True)
-    for post in sender.post_set.distinct().values('thread_id').iterator():
-        if not post['thread_id'] in threads:
-            threads.append(post['thread_id'])
-    for post in Post.objects.filter(user=sender):
-        post.delete()
-    for thread in Thread.objects.filter(id__in=threads):
-        thread.sync()
-        thread.save(force_update=True)
-
-delete_user_content.connect(delete_user_content_handler, dispatch_uid="delete_user_threads_posts")
-
-
-def move_forum_content_handler(sender, **kwargs):
-    Thread.objects.filter(forum=sender).update(forum=kwargs['move_to'])
-    Post.objects.filter(forum=sender).update(forum=kwargs['move_to'])
-    Karma.objects.filter(forum=sender).update(forum=kwargs['move_to'])
-    Change.objects.filter(forum=sender).update(forum=kwargs['move_to'])
-    Checkpoint.objects.filter(forum=sender).update(forum=kwargs['move_to'])
-
-move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_threads_posts")
-
-
-def move_thread_handler(sender, **kwargs):
-    Post.objects.filter(thread=sender).update(forum=kwargs['move_to'])
-    Karma.objects.filter(thread=sender).update(forum=kwargs['move_to'])
-    Change.objects.filter(thread=sender).update(forum=kwargs['move_to'])
-    Checkpoint.objects.filter(thread=sender).update(forum=kwargs['move_to'])
-
-move_thread.connect(move_thread_handler, dispatch_uid="move_thread")
-
-
-def merge_thread_handler(sender, **kwargs):
-    Post.objects.filter(thread=sender).update(thread=kwargs['new_thread'], merge=F('merge') + kwargs['merge'])
-    Karma.objects.filter(thread=sender).update(thread=kwargs['new_thread'])
-    Change.objects.filter(thread=sender).update(thread=kwargs['new_thread'])
-    Checkpoint.objects.filter(thread=sender).delete()
-
-merge_thread.connect(merge_thread_handler, dispatch_uid="merge_threads")
-
-
-def move_posts_handler(sender, **kwargs):
-    Change.objects.filter(post=sender).update(forum=kwargs['move_to'].forum, thread=kwargs['move_to'])
-    Karma.objects.filter(post=sender).update(forum=kwargs['move_to'].forum, thread=kwargs['move_to'])
-    if sender.checkpoints:
-        prev_post = Post.objects.filter(thread=sender.thread_id).filter(merge__lte=sender.merge).exclude(id=sender.pk).order_by('merge', '-id')[:1][0]
-        Checkpoint.objects.filter(post=sender).update(post=prev_post)
-        prev_post.checkpoints = True
-        prev_post.save(force_update=True)
-    sender.checkpoints = False
-
-move_post.connect(move_posts_handler, dispatch_uid="move_posts")
-
-
-def merge_posts_handler(sender, **kwargs):
-    Change.objects.filter(post=sender).update(post=kwargs['new_post'])
-    Checkpoint.objects.filter(post=sender).update(post=kwargs['new_post'])
-    Karma.objects.filter(post=sender).update(post=kwargs['new_post'])
-    if sender.checkpoints:
-        kwargs['new_post'].checkpoints = True
-    kwargs['new_post'].upvotes += self.upvotes
-    kwargs['new_post'].downvotes += self.downvotes
-    kwargs['new_post'].score += self.score
-
-merge_post.connect(merge_posts_handler, dispatch_uid="merge_posts")

+ 0 - 161
misago/threads/tests.py

@@ -1,161 +0,0 @@
-from django.core.management import call_command
-from django.utils import timezone, unittest
-from django.test.client import RequestFactory
-from misago.settings.models import Setting
-from misago.forums.models import Forum
-from misago.sessions.sessions import SessionMock
-from misago.threads.models import Thread, Post, Change, Checkpoint
-from misago.threads.testutils import create_thread, create_post 
-from misago.users.models import User
-
-class DeleteThreadTestCase(unittest.TestCase):
-    def setUp(self):
-        call_command('loaddata', quiet=True)
-        self.factory = RequestFactory()
-        
-        Post.objects.all().delete()
-        Thread.objects.all().delete()
-        User.objects.all().delete()
-        self.user = User.objects.create_user('Neddart', 'ned@test.com', 'pass')
-        self.user_alt = User.objects.create_user('Robert', 'rob@test.com', 'pass')
-        self.forum = Forum.objects.get(id=1)
-        
-        self.thread = create_thread(self.forum)
-        self.post = create_post(self.thread, self.user)
-     
-    def make_request(self, user=None):
-        request = self.factory.get('/customer/details')
-        request.session = SessionMock()
-        request.user = user
-        request.META['HTTP_USER_AGENT'] = 'TestAgent'
-        return request
-        
-    def test_deletion_owned(self):
-        """Check if user content delete results in correct deletion of thread"""
-        # Assert that test has been correctly initialised
-        self.assertEqual(Thread.objects.count(), 1)
-        self.assertEqual(Post.objects.count(), 1)
-        
-        # Run test
-        self.user.delete_content()
-        self.assertEqual(Thread.objects.count(), 0)
-        self.assertEqual(Post.objects.count(), 0)
-        
-    def test_deletion_other(self):
-        """Check if user content delete results in correct deletion of post"""
-        # Create second post
-        self.post = create_post(self.thread, self.user_alt)
-        
-        # Assert that test has been correctly initialised
-        self.assertEqual(Thread.objects.count(), 1)
-        self.assertEqual(Post.objects.count(), 2)
-        
-        # Run test
-        self.user_alt.delete_content()
-        self.assertEqual(Thread.objects.count(), 1)
-        self.assertEqual(Post.objects.count(), 1)
-        
-    def test_deletion_owned_other(self):
-        """Check if user content delete results in correct deletion of thread and posts"""
-        # Create second post
-        self.post = create_post(self.thread, self.user_alt)
-                
-        # Assert that test has been correctly initialised
-        self.assertEqual(Thread.objects.count(), 1)
-        self.assertEqual(Post.objects.count(), 2)
-        
-        # Run test
-        self.user.delete_content()
-        self.assertEqual(Thread.objects.count(), 0)
-        self.assertEqual(Post.objects.count(), 0)
-        
-    def test_deletion_checkpoints(self):
-        """Check if user content delete results in correct update of thread checkpoints"""
-        # Create an instance of a GET request.
-        request = self.make_request(self.user_alt)
-        
-        # Create second and third post
-        self.post = create_post(self.thread, self.user)
-        self.post_sec = create_post(self.thread, self.user_alt)
-        self.post_sec.set_checkpoint(request, 'locked')
-        self.post_sec.save(force_update=True)
-                
-        # Assert that test has been correctly initialised
-        self.assertEqual(Thread.objects.count(), 1)
-        self.assertEqual(Post.objects.count(), 3)
-        
-        # Run test
-        self.user_alt.delete_content()
-        self.assertEqual(Thread.objects.count(), 1)
-        self.assertEqual(Post.objects.count(), 2)
-        self.assertEqual(Checkpoint.objects.count(), 1)
-        self.assertEqual(Post.objects.filter(checkpoints=True).count(), 1)
-        self.assertEqual(Post.objects.get(id=self.post.pk).checkpoints, True)
-        self.assertEqual(Post.objects.get(id=self.post.pk).checkpoint_set.count(), 1)
-    
-    def test_threads_merge(self):
-        """Check if threads are correctly merged"""
-        # Create second thread
-        self.thread_b = create_thread(self.forum)
-        self.post_b = create_post(self.thread_b, self.user)
-        
-        # Merge threads
-        self.thread_b.merge_with(self.thread, 1)
-        self.thread_b.delete()
-        self.thread.merges += 1
-        self.thread.save(force_update=True)
-        
-        # See if merger was correct
-        self.assertEqual(Thread.objects.count(), 1)
-        self.assertEqual(Post.objects.count(), 2)
-        last_post = Post.objects.order_by('-id')[:1][0]
-        self.assertEqual(last_post.thread_id, self.thread.pk)
-        self.assertEqual(last_post.merge, 1)
-                
-        # Create third thread
-        self.thread_c = create_thread(self.forum)
-        self.post_c = create_post(self.thread_c, self.user)
-              
-        # Merge first thread into third one
-        self.thread.merge_with(self.thread_c, 1)
-        self.thread.delete()
-
-        # See if merger was correct
-        self.assertEqual(Thread.objects.count(), 1)
-        self.assertEqual(Post.objects.count(), 3)
-        last_post = Post.objects.get(id=last_post.pk)
-        self.assertEqual(last_post.thread_id, self.thread_c.pk)
-        self.assertEqual(last_post.merge, 2)
-        
-    def test_threads_move_checkpoints(self):
-        """Check if post_move correctly handles checkpoints"""
-        # Create thread with two posts
-        self.thread_b = create_thread(self.forum)
-        self.post_b = create_post(self.thread_b, self.user)
-        self.post_c = create_post(self.thread_b, self.user)
-        
-        # Create an instance of a GET request.
-        request = self.make_request(self.user)
-        
-        # Add checkpoint to post c
-        self.post_c.set_checkpoint(request, 'locked')
-        self.post_c.save(force_update=True)
-        
-        # Move post and sync threads
-        self.post_c.move_to(self.thread)
-        self.post_c.save(force_update=True)
-        self.thread.sync()
-        self.thread.save(force_update=True)
-        self.thread_b.sync()
-        self.thread_b.save(force_update=True)
-        
-        # See threads and post counters
-        self.assertEqual(Thread.objects.count(), 2)
-        self.assertEqual(Post.objects.count(), 3)
-        
-        # Refresh post b
-        self.post_b = Post.objects.get(id=self.post_b.pk)
-        
-        # Check if post b has post's c checkpoints
-        self.assertEqual(self.post_b.checkpoints, True)
-        self.assertEqual(self.post_b.checkpoint_set.count(), 1)

+ 0 - 40
misago/threads/testutils.py

@@ -1,40 +0,0 @@
-from django.utils import timezone
-from misago.threads.models import Thread, Post, Change, Checkpoint
-
-def create_thread(forum):
-    thread = Thread()
-    thread.forum = forum
-    thread.name = 'Test Thread'
-    thread.slug = 'test-thread'
-    thread.start = timezone.now()
-    thread.last = timezone.now()
-    thread.save(force_insert=True)
-    return thread
-
-
-def create_post(thread, user):
-    now = timezone.now()
-    post = Post()
-    post.forum = thread.forum
-    post.thread = thread
-    post.date = now
-    post.user = user
-    post.user_name = user.username
-    post.ip = '127.0.0.1'
-    post.agent = 'No matter'
-    post.post = 'No matter'
-    post.post_preparsed = 'No matter'
-    post.save(force_insert=True)
-    if not thread.start_post:
-        thread.start = now
-        thread.start_post = post
-        thread.start_poster = user
-        thread.start_poster_name = user.username
-        thread.start_poster_slug = user.username_slug
-    thread.last = now
-    thread.last_post = post
-    thread.last_poster = user
-    thread.last_poster_name = user.username
-    thread.last_poster_slug = user.username_slug
-    thread.save(force_update=True)
-    return post

+ 0 - 34
misago/threads/urls.py

@@ -1,34 +0,0 @@
-from django.conf.urls import patterns, url
-
-urlpatterns = patterns('misago.threads.views',
-    url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', 'ThreadsView', name="forum"),
-    url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/(?P<page>\d+)/$', 'ThreadsView', name="forum"),
-    url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/new/$', 'PostingView', name="thread_new", kwargs={'mode': 'new_thread'}),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/$', 'ThreadView', name="thread"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/last/$', 'LastReplyView', name="thread_last"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/find-(?P<post>\d+)/$', 'FindReplyView', name="thread_find"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/new/$', 'NewReplyView', name="thread_new"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/moderated/$', 'FirstModeratedView', name="thread_moderated"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/reported/$', 'FirstReportedView', name="thread_reported"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/show-hidden/$', 'ShowHiddenRepliesView', name="thread_show_hidden"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/$', 'WatchThreadView', name="thread_watch"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/email/$', 'WatchEmailThreadView', name="thread_watch_email"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/$', 'UnwatchThreadView', name="thread_unwatch"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/email/$', 'UnwatchEmailThreadView', name="thread_unwatch_email"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<page>\d+)/$', 'ThreadView', name="thread"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/reply/$', 'PostingView', name="thread_reply", kwargs={'mode': 'new_post'}),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<quote>\d+)/reply/$', 'PostingView', name="thread_reply", kwargs={'mode': 'new_post'}),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/edit/$', 'PostingView', name="thread_edit", kwargs={'mode': 'edit_thread'}),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/edit/$', 'PostingView', name="post_edit", kwargs={'mode': 'edit_post'}),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/delete/$', 'DeleteView', name="thread_delete", kwargs={'mode': 'delete_thread'}),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/hide/$', 'DeleteView', name="thread_hide", kwargs={'mode': 'hide_thread'}),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/delete/$', 'DeleteView', name="post_delete", kwargs={'mode': 'delete_post'}),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/hide/$', 'DeleteView', name="post_hide", kwargs={'mode': 'hide_post'}),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/info/$', 'DetailsView', name="post_info"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/upvote/$', 'UpvotePostView', name="post_upvote"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/downvote/$', 'DownvotePostView', name="post_downvote"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/votes/$', 'KarmaVotesView', name="post_votes"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/$', 'ChangelogView', name="changelog"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/$', 'ChangelogDiffView', name="changelog_diff"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/revert/$', 'ChangelogRevertView', name="changelog_revert"),
-)

+ 0 - 9
misago/threads/views/__init__.py

@@ -1,9 +0,0 @@
-from misago.threads.views.list import *
-from misago.threads.views.jumps import *
-from misago.threads.views.thread import *
-from misago.threads.views.delete import *
-from misago.threads.views.karmas import *
-from misago.threads.views.posting import *
-from misago.threads.views.details import *
-from misago.threads.views.changelog import *
-

+ 0 - 14
misago/threads/views/base.py

@@ -1,14 +0,0 @@
-from django.core.urlresolvers import reverse
-from django.shortcuts import redirect
-from misago.utils import make_pagination
-
-class BaseView(object):
-    def __new__(cls, request, **kwargs):
-        obj = super(BaseView, cls).__new__(cls)
-        return obj(request, **kwargs)
-
-    def redirect_to_post(self, post):
-        pagination = make_pagination(0, self.request.acl.threads.filter_posts(self.request, self.thread, self.thread.post_set).filter(id__lte=post.pk).count(), self.request.settings.posts_per_page)
-        if pagination['total'] > 1:
-            return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug, 'page': pagination['total']}) + ('#post-%s' % post.pk))
-        return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % post.pk))

+ 0 - 106
misago/threads/views/delete.py

@@ -1,106 +0,0 @@
-from django.core.urlresolvers import reverse
-from django.shortcuts import redirect
-from django.template import RequestContext
-from django.utils import timezone
-from django.utils.translation import ugettext as _
-from misago.acl.utils import ACLError403, ACLError404
-from misago.forums.models import Forum
-from misago.messages import Message
-from misago.threads.models import Thread, Post
-from misago.threads.views.base import BaseView
-from misago.views import error403, error404
-from misago.utils import make_pagination
-
-class DeleteView(BaseView):
-    def fetch_thread(self, kwargs):
-        self.thread = Thread.objects.get(pk=kwargs['thread'])
-        self.forum = self.thread.forum
-        self.request.acl.forums.allow_forum_view(self.forum)
-        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
-        if self.mode in ['tread_delete', 'hide_thread']:
-            self.request.acl.threads.allow_delete_thread(
-                                                         self.request.user,
-                                                         self.forum,
-                                                         self.thread,
-                                                         self.thread.start_post,
-                                                         self.mode == 'delete_thread')
-            # Assert we are not user trying to delete thread with replies
-            acl = self.request.acl.threads.get_role(self.thread.forum_id)
-            if not acl['can_delete_threads']:
-                if self.thread.post_set.exclude(user_id=self.request.user.id).count() > 0:
-                    raise ACLError403(_("Somebody has already replied to this thread. You cannot delete it."))
-
-    def fetch_post(self, kwargs):
-        self.post = self.thread.post_set.get(pk=kwargs['post'])
-        if self.post.pk == self.thread.start_post_id:
-            raise Post.DoesNotExist()
-        self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
-        self.request.acl.threads.allow_delete_post(
-                                                   self.request.user,
-                                                   self.forum,
-                                                   self.thread,
-                                                   self.post,
-                                                   self.mode == 'delete_post')
-        acl = self.request.acl.threads.get_role(self.thread.forum_id)
-        if not acl['can_delete_posts'] and self.thread.post_set.filter(id__gt=self.post.pk).count() > 0:
-            raise ACLError403(_("Somebody has already replied to this post, you cannot delete it."))
-
-    def __call__(self, request, **kwargs):
-        self.request = request
-        self.mode = kwargs['mode']
-        try:
-            if not request.user.is_authenticated():
-                raise ACLError403(_("Guest, you have to sign-in in order to be able to delete replies."))
-            self.fetch_thread(kwargs)
-            if self.mode in ['hide_post', 'delete_post']:
-                self.fetch_post(kwargs)
-        except (Thread.DoesNotExist, Post.DoesNotExist):
-            return error404(self.request)
-        except ACLError403 as e:
-            return error403(request, e.message)
-        except ACLError404 as e:
-            return error404(request, e.message)
-
-        if self.mode == 'delete_thread':
-            self.thread.delete()
-            self.forum.sync()
-            self.forum.save(force_update=True)
-            request.messages.set_flash(Message(_('Thread "%(thread)s" has been deleted.') % {'thread': self.thread.name}), 'success', 'threads')
-            return redirect(reverse('forum', kwargs={'forum': self.thread.forum.pk, 'slug': self.thread.forum.slug}))
-
-        if self.mode == 'hide_thread':
-            self.thread.start_post.deleted = True
-            self.thread.start_post.save(force_update=True)
-            self.thread.last_post.set_checkpoint(request, 'deleted')
-            self.thread.last_post.save(force_update=True)
-            self.thread.sync()
-            self.thread.save(force_update=True)
-            self.forum.sync()
-            self.forum.save(force_update=True)
-            request.messages.set_flash(Message(_('Thread "%(thread)s" has been deleted.') % {'thread': self.thread.name}), 'success', 'threads')
-            if request.acl.threads.can_see_deleted_threads(self.thread.forum):
-                return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}))
-            return redirect(reverse('forum', kwargs={'forum': self.thread.forum.pk, 'slug': self.thread.forum.slug}))
-
-        if self.mode == 'delete_post':
-            self.post.delete()
-            self.thread.sync()
-            self.thread.save(force_update=True)
-            self.forum.sync()
-            self.forum.save(force_update=True)
-            request.messages.set_flash(Message(_("Selected Reply has been deleted.")), 'success', 'threads')
-            return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}))
-
-        if self.mode == 'hide_post':
-            self.post.deleted = True
-            self.post.edit_date = timezone.now()
-            self.post.edit_user = request.user
-            self.post.edit_user_name = request.user.username
-            self.post.edit_user_slug = request.user.username_slug
-            self.post.save(force_update=True)
-            self.thread.sync()
-            self.thread.save(force_update=True)
-            self.forum.sync()
-            self.forum.save(force_update=True)
-            request.messages.set_flash(Message(_("Selected Reply has been deleted.")), 'success', 'threads_%s' % self.post.pk)
-            return self.redirect_to_post(self.post)

+ 0 - 41
misago/threads/views/details.py

@@ -1,41 +0,0 @@
-from django.template import RequestContext
-from misago.acl.utils import ACLError403, ACLError404
-from misago.forums.models import Forum
-from misago.threads.models import Thread, Post
-from misago.threads.views.base import BaseView
-from misago.views import error403, error404
-
-class DetailsView(BaseView):
-    def fetch_target(self, kwargs):
-        self.thread = Thread.objects.get(pk=kwargs['thread'])
-        self.forum = self.thread.forum
-        self.proxy = Forum.objects.parents_aware_forum(self.forum)
-        self.request.acl.forums.allow_forum_view(self.forum)
-        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
-        self.parents = Forum.objects.forum_parents(self.forum.pk, True)
-        self.post = Post.objects.select_related('user').get(pk=kwargs['post'], thread=self.thread.pk)
-        self.post.thread = self.thread
-        self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
-        self.request.acl.users.allow_details_view()
-
-    def __call__(self, request, **kwargs):
-        self.request = request
-        self.forum = None
-        self.thread = None
-        self.post = None
-        try:
-            self.fetch_target(kwargs)
-        except (Forum.DoesNotExist, Thread.DoesNotExist, Post.DoesNotExist):
-            return error404(self.request)
-        except ACLError403 as e:
-            return error403(request, e.message)
-        except ACLError404 as e:
-            return error404(request, e.message)
-        return request.theme.render_to_response('threads/details.html',
-                                                {
-                                                 'forum': self.forum,
-                                                 'parents': self.parents,
-                                                 'thread': self.thread,
-                                                 'post': self.post,
-                                                 },
-                                                context_instance=RequestContext(request))

+ 0 - 43
misago/threads/views/karmas.py

@@ -1,43 +0,0 @@
-from django.template import RequestContext
-from misago.acl.utils import ACLError403, ACLError404
-from misago.forums.models import Forum
-from misago.threads.models import Thread, Post
-from misago.threads.views.base import BaseView
-from misago.views import error403, error404
-
-class KarmaVotesView(BaseView):
-    def fetch_target(self, kwargs):
-        self.thread = Thread.objects.get(pk=kwargs['thread'])
-        self.forum = self.thread.forum
-        self.proxy = Forum.objects.parents_aware_forum(self.forum)
-        self.request.acl.forums.allow_forum_view(self.forum)
-        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
-        self.parents = Forum.objects.forum_parents(self.forum.pk, True)
-        self.post = Post.objects.select_related('user').get(pk=kwargs['post'], thread=self.thread.pk)
-        self.post.thread = self.thread
-        self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
-        self.request.acl.threads.allow_post_votes_view(self.forum)
-
-    def __call__(self, request, **kwargs):
-        self.request = request
-        self.forum = None
-        self.thread = None
-        self.post = None
-        try:
-            self.fetch_target(kwargs)
-        except (Forum.DoesNotExist, Thread.DoesNotExist, Post.DoesNotExist):
-            return error404(self.request)
-        except ACLError403 as e:
-            return error403(request, e.message)
-        except ACLError404 as e:
-            return error404(request, e.message)
-        return request.theme.render_to_response('threads/karmas.html',
-                                                {
-                                                 'forum': self.forum,
-                                                 'parents': self.parents,
-                                                 'thread': self.thread,
-                                                 'post': self.post,
-                                                 'upvotes': self.post.karma_set.filter(score=1),
-                                                 'downvotes': self.post.karma_set.filter(score=-1),
-                                                 },
-                                                context_instance=RequestContext(request))

+ 0 - 395
misago/threads/views/posting.py

@@ -1,395 +0,0 @@
-from datetime import timedelta
-from django.core.urlresolvers import reverse
-from django.shortcuts import redirect
-from django.template import RequestContext
-from django.utils import timezone
-from django.utils.translation import ugettext as _
-from misago.acl.utils import ACLError403, ACLError404
-from misago.forms import FormLayout
-from misago.forums.models import Forum
-from misago.markdown import post_markdown
-from misago.messages import Message
-from misago.template.templatetags.django2jinja import date
-from misago.threads.forms import PostForm
-from misago.threads.models import Thread, Post
-from misago.threads.views.base import BaseView
-from misago.views import error403, error404
-from misago.utils import make_pagination, slugify, ugettext_lazy
-from misago.watcher.models import ThreadWatch
-
-class PostingView(BaseView):
-    def fetch_target(self, kwargs):
-        if self.mode == 'new_thread':
-            self.fetch_forum(kwargs)
-        else:
-            self.fetch_thread(kwargs)
-            if self.mode == 'edit_thread':
-                self.fetch_post(self.thread.start_post_id)
-            if self.mode == 'edit_post':
-                self.fetch_post(kwargs['post'])
-
-    def fetch_forum(self, kwargs):
-        self.forum = Forum.objects.get(pk=kwargs['forum'], type='forum')
-        self.proxy = Forum.objects.parents_aware_forum(self.forum)
-        self.request.acl.forums.allow_forum_view(self.forum)
-        self.request.acl.threads.allow_new_threads(self.proxy)
-        self.parents = Forum.objects.forum_parents(self.forum.pk, True)
-
-    def fetch_thread(self, kwargs):
-        self.thread = Thread.objects.get(pk=kwargs['thread'])
-        self.forum = self.thread.forum
-        self.proxy = Forum.objects.parents_aware_forum(self.forum)
-        self.request.acl.forums.allow_forum_view(self.forum)
-        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
-        self.request.acl.threads.allow_reply(self.proxy, self.thread)
-        self.parents = Forum.objects.forum_parents(self.forum.pk, True)
-        if kwargs.get('quote'):
-            self.quote = Post.objects.select_related('user').get(pk=kwargs['quote'], thread=self.thread.pk)
-            self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.quote)
-
-    def fetch_post(self, post):
-        self.post = self.thread.post_set.get(pk=post)
-        self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
-        if self.mode == 'edit_thread':
-            self.request.acl.threads.allow_thread_edit(self.request.user, self.proxy, self.thread, self.post)
-        if self.mode == 'edit_post':
-            self.request.acl.threads.allow_reply_edit(self.request.user, self.proxy, self.thread, self.post)
-
-    def get_form(self, bound=False):
-        initial = {}
-        if self.mode == 'edit_thread':
-            initial['thread_name'] = self.thread.name
-        if self.mode in ['edit_thread', 'edit_post']:
-            initial['post'] = self.post.post
-        if self.quote:
-            quote_post = []
-            if self.quote.user:
-                quote_post.append('@%s' % self.quote.user.username)
-            else:
-                quote_post.append('@%s' % self.quote.user_name)
-            for line in self.quote.post.splitlines():
-                quote_post.append('> %s' % line)
-            quote_post.append('\n')
-            initial['post'] = '\n'.join(quote_post)
-
-        if bound:
-            return PostForm(self.request.POST, request=self.request, mode=self.mode, initial=initial)
-        return PostForm(request=self.request, mode=self.mode, initial=initial)
-
-    def __call__(self, request, **kwargs):
-        self.request = request
-        self.forum = None
-        self.thread = None
-        self.quote = None
-        self.post = None
-        self.parents = None
-        self.mode = kwargs.get('mode')
-        if self.request.POST.get('quick_reply') and self.mode == 'new_post':
-            self.mode = 'new_post_quick'
-        try:
-            self.fetch_target(kwargs)
-            if not request.user.is_authenticated():
-                raise ACLError403(_("Guest, you have to sign-in in order to post replies."))
-        except (Forum.DoesNotExist, Thread.DoesNotExist, Post.DoesNotExist):
-            return error404(self.request)
-        except ACLError403 as e:
-            return error403(request, e.message)
-        except ACLError404 as e:
-            return error404(request, e.message)
-
-        message = request.messages.get_message('threads')
-        if request.method == 'POST':
-            form = self.get_form(True)
-            # Show message preview
-            if 'preview' in request.POST:
-                if form['post'].value():
-                    md, preparsed = post_markdown(request, form['post'].value())
-                else:
-                    md, preparsed = None, None
-                form.empty_errors()
-                return request.theme.render_to_response('threads/posting.html',
-                                                        {
-                                                         'mode': self.mode,
-                                                         'forum': self.forum,
-                                                         'thread': self.thread,
-                                                         'post': self.post,
-                                                         'quote': self.quote,
-                                                         'parents': self.parents,
-                                                         'message': message,
-                                                         'preview': preparsed,
-                                                         'form': FormLayout(form),
-                                                         },
-                                                        context_instance=RequestContext(request));
-            # Commit form to database
-            if form.is_valid():                
-                # Record original vars if user is editing 
-                if self.mode in ['edit_thread', 'edit_post']:
-                    old_name = self.thread.name
-                    old_post = self.post.post
-                    # If there is no change, throw user back
-                    changed_name = (old_name != form.cleaned_data['thread_name']) if self.mode == 'edit_thread' else False
-                    changed_post = old_post != form.cleaned_data['post']
-                    changed_anything = changed_name or changed_post
-
-                # Some extra initialisation
-                now = timezone.now()
-                md = None
-                moderation = False
-                if not request.acl.threads.acl[self.forum.pk]['can_approve']:
-                    if self.mode == 'new_thread' and request.acl.threads.acl[self.forum.pk]['can_start_threads'] == 1:
-                        moderation = True
-                    if self.mode in ['new_post', 'new_post_quick'] and request.acl.threads.acl[self.forum.pk]['can_write_posts'] == 1:
-                        moderation = True
-
-                # Get or create new thread
-                if self.mode == 'new_thread':
-                    thread = Thread.objects.create(
-                                                   forum=self.forum,
-                                                   name=form.cleaned_data['thread_name'],
-                                                   slug=slugify(form.cleaned_data['thread_name']),
-                                                   start=now,
-                                                   last=now,
-                                                   moderated=moderation,
-                                                   score=request.settings['thread_ranking_initial_score'],
-                                                   )
-                    if moderation:
-                        thread.replies_moderated += 1
-                else:
-                    thread = self.thread
-                    if self.mode == 'edit_thread':
-                        thread.name = form.cleaned_data['thread_name']
-                        thread.slug = slugify(form.cleaned_data['thread_name'])
-                thread.previous_last = thread.last 
-
-                # Create new message
-                if self.mode in ['new_thread', 'new_post', 'new_post_quick']:
-                    # Use last post instead?
-                    if self.mode in ['new_post', 'new_post_quick']:
-                        merge_diff = (now - self.thread.last)
-                        merge_diff = (merge_diff.days * 86400) + merge_diff.seconds
-                    if (self.mode in ['new_post', 'new_post_quick']
-                        and request.settings.post_merge_time
-                        and merge_diff < (request.settings.post_merge_time * 60)
-                        and self.thread.last_poster_id == request.user.id):
-                        # Overtake posting
-                        post = self.thread.last_post
-                        post.appended = True
-                        post.moderated = moderation
-                        post.date = now
-                        post.post = '%s\n\n- - -\n**%s**\n%s' % (post.post, _("Added on %(date)s:") % {'date': date(now, 'SHORT_DATETIME_FORMAT')}, form.cleaned_data['post'])
-                        md, post.post_preparsed = post_markdown(request, post.post)
-                        post.save(force_update=True)
-                        thread.last = now
-                        thread.save(force_update=True)
-                        self.forum.last = now
-                        self.forum.save(force_update=True)
-                        # Ignore rest of posting action
-                        request.messages.set_flash(Message(_("Your reply has been added to previous one.")), 'success', 'threads_%s' % post.pk)
-                        return self.redirect_to_post(post)
-                    else:
-                        md, post_preparsed = post_markdown(request, form.cleaned_data['post'])
-                        post = Post.objects.create(
-                                                   forum=self.forum,
-                                                   thread=thread,
-                                                   merge=thread.merges,
-                                                   user=request.user,
-                                                   user_name=request.user.username,
-                                                   ip=request.session.get_ip(request),
-                                                   agent=request.META.get('HTTP_USER_AGENT'),
-                                                   post=form.cleaned_data['post'],
-                                                   post_preparsed=post_preparsed,
-                                                   date=now,
-                                                   moderated=moderation,
-                                                   )
-                        post.appended = False
-                elif changed_post:
-                    # Change message
-                    post = self.post
-                    post.post = form.cleaned_data['post']
-                    md, post.post_preparsed = post_markdown(request, form.cleaned_data['post'])
-                    post.edits += 1
-                    post.edit_date = now
-                    post.edit_user = request.user
-                    post.edit_user_name = request.user.username
-                    post.edit_user_slug = request.user.username_slug
-                    post.save(force_update=True)
-
-                # Record this edit in changelog?
-                if self.mode in ['edit_thread', 'edit_post'] and changed_anything:
-                    self.post.change_set.create(
-                                                forum=self.forum,
-                                                thread=self.thread,
-                                                post=self.post,
-                                                user=request.user,
-                                                user_name=request.user.username,
-                                                user_slug=request.user.username_slug,
-                                                date=now,
-                                                ip=request.session.get_ip(request),
-                                                agent=request.META.get('HTTP_USER_AGENT'),
-                                                reason=form.cleaned_data['edit_reason'],
-                                                size=len(self.post.post),
-                                                change=len(self.post.post) - len(old_post),
-                                                thread_name_old=old_name if self.mode == 'edit_thread' and form.cleaned_data['thread_name'] != old_name else None,
-                                                thread_name_new=self.thread.name if self.mode == 'edit_thread' and form.cleaned_data['thread_name'] != old_name else None,
-                                                post_content=old_post,
-                                                )
-
-                # Set thread start post and author data
-                if self.mode == 'new_thread':
-                    thread.start_post = post
-                    thread.start_poster = request.user
-                    thread.start_poster_name = request.user.username
-                    thread.start_poster_slug = request.user.username_slug
-                    if request.user.rank and request.user.rank.style:
-                        thread.start_poster_style = request.user.rank.style
-                    # Reward user for posting new thread?
-                    if not request.user.last_post or request.user.last_post < timezone.now() - timedelta(seconds=request.settings['score_reward_new_post_cooldown']):
-                        request.user.score += request.settings['score_reward_new_thread']
-
-                # New post - increase post counters, thread score
-                # Notify quoted post author and close thread if it has hit limit
-                if self.mode in ['new_post', 'new_post_quick']:
-                    if moderation:
-                        thread.replies_moderated += 1
-                    else:
-                        thread.replies += 1
-                        if thread.last_poster_id != request.user.pk:
-                            thread.score += request.settings['thread_ranking_reply_score']
-                        # Notify quoted poster of reply?
-                        if self.quote and self.quote.user_id and self.quote.user_id != request.user.pk and not self.quote.user.is_ignoring(request.user):
-                            alert = self.quote.user.alert(ugettext_lazy("%(username)s has replied to your post in thread %(thread)s").message)
-                            alert.profile('username', request.user)
-                            alert.post('thread', self.thread, post)
-                            alert.save_all()
-                        if (self.request.settings.thread_length > 0
-                            and not thread.closed
-                            and thread.replies >= self.request.settings.thread_length):
-                            thread.closed = True
-                            post.set_checkpoint(self.request, 'limit')
-                    # Reward user for posting new post?
-                    if not post.appended and (not request.user.last_post or request.user.last_post < timezone.now() - timedelta(seconds=request.settings['score_reward_new_post_cooldown'])):
-                        request.user.score += request.settings['score_reward_new_post']
-
-                # Update last poster data
-                if not moderation and self.mode not in ['edit_thread', 'edit_post']:
-                    thread.last = now
-                    thread.last_post = post
-                    thread.last_poster = request.user
-                    thread.last_poster_name = request.user.username
-                    thread.last_poster_slug = request.user.username_slug
-                    thread.last_poster_style = request.user.rank.style
-
-                # Final update of thread entry
-                if self.mode != 'edit_post':
-                    thread.save(force_update=True)
-
-                # Update forum and monitor
-                if not moderation:
-                    if self.mode == 'new_thread':
-                        self.request.monitor['threads'] = int(self.request.monitor['threads']) + 1
-                        self.forum.threads += 1
-
-                    if self.mode in ['new_thread', 'new_post', 'new_post_quick']:
-                        self.request.monitor['posts'] = int(self.request.monitor['posts']) + 1
-                        self.forum.posts += 1
-
-                    if self.mode in ['new_thread', 'new_post', 'new_post_quick'] or (
-                        self.mode == 'edit_thread'
-                        and self.forum.last_thread_id == thread.pk
-                        and self.forum.last_thread_name != thread.name):
-                        self.forum.last_thread = thread
-                        self.forum.last_thread_name = thread.name
-                        self.forum.last_thread_slug = thread.slug
-                        self.forum.last_thread_date = thread.last
-
-                    if self.mode in ['new_thread', 'new_post', 'new_post_quick']:
-                        self.forum.last_poster = thread.last_poster
-                        self.forum.last_poster_name = thread.last_poster_name
-                        self.forum.last_poster_slug = thread.last_poster_slug
-                        self.forum.last_poster_style = thread.last_poster_style
-
-                    if self.mode != 'edit_post':
-                        self.forum.save(force_update=True)
-
-                # Update user
-                if not moderation:
-                    if self.mode == 'new_thread':
-                        request.user.threads += 1
-                    request.user.posts += 1
-                if self.mode in ['new_thread', 'new_post', 'new_post_quick']:
-                    request.user.last_post = thread.last
-                    request.user.save(force_update=True)
-                    
-                # Notify users about post
-                if md:
-                    try:
-                        if self.quote and self.quote.user_id:
-                            del md.mentions[self.quote.user.username_slug]
-                    except KeyError:
-                        pass
-                    if md.mentions:
-                        post.notify_mentioned(request, md.mentions)
-                        post.save(force_update=True)
-
-                # Set thread watch status
-                if self.mode == 'new_thread' and request.user.subscribe_start:
-                    ThreadWatch.objects.create(
-                                               user=request.user,
-                                               forum=self.forum,
-                                               thread=thread,
-                                               last_read=now,
-                                               email=(request.user.subscribe_start == 2),
-                                               )
-                    
-                if self.mode in ['new_post', 'new_post_quick'] and request.user.subscribe_reply:
-                    try:
-                        watcher = ThreadWatch.objects.get(user=request.user, thread=self.thread)
-                    except ThreadWatch.DoesNotExist:
-                        ThreadWatch.objects.create(
-                                                   user=request.user,
-                                                   forum=self.forum,
-                                                   thread=thread,
-                                                   last_read=now,
-                                                   email=(request.user.subscribe_reply == 2),
-                                                   )
-
-                # Set flash and redirect user to his post
-                if self.mode == 'new_thread':
-                    if moderation:
-                        request.messages.set_flash(Message(_("New thread has been posted. It will be hidden from other members until moderator reviews it.")), 'success', 'threads')
-                    else:
-                        request.messages.set_flash(Message(_("New thread has been posted.")), 'success', 'threads')
-                    return redirect(reverse('thread', kwargs={'thread': thread.pk, 'slug': thread.slug}) + ('#post-%s' % post.pk))
-
-                if self.mode in ['new_post', 'new_post_quick']:
-                    thread.email_watchers(request, post)
-                    if moderation:
-                        request.messages.set_flash(Message(_("Your reply has been posted. It will be hidden from other members until moderator reviews it.")), 'success', 'threads_%s' % post.pk)
-                    else:
-                        request.messages.set_flash(Message(_("Your reply has been posted.")), 'success', 'threads_%s' % post.pk)
-                    return self.redirect_to_post(post)
-
-                if self.mode == 'edit_thread':
-                    request.messages.set_flash(Message(_("Your thread has been edited.")), 'success', 'threads_%s' % self.post.pk)
-                if self.mode == 'edit_post':
-                    request.messages.set_flash(Message(_("Your reply has been edited.")), 'success', 'threads_%s' % self.post.pk)
-                    return self.redirect_to_post(self.post)
-                return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % self.post.pk))
-            message = Message(form.non_field_errors()[0], 'error')
-        else:
-            form = self.get_form()
-
-        # Merge proxy into forum
-        self.forum.closed = self.proxy.closed
-        return request.theme.render_to_response('threads/posting.html',
-                                                {
-                                                 'mode': self.mode,
-                                                 'forum': self.forum,
-                                                 'thread': self.thread,
-                                                 'post': self.post,
-                                                 'quote': self.quote,
-                                                 'parents': self.parents,
-                                                 'message': message,
-                                                 'form': FormLayout(form),
-                                                 },
-                                                context_instance=RequestContext(request));

+ 0 - 566
misago/threads/views/thread.py

@@ -1,566 +0,0 @@
-from django.core.urlresolvers import reverse
-from django import forms
-from django.db.models import F
-from django.forms import ValidationError
-from django.shortcuts import redirect
-from django.template import RequestContext
-from django.utils import timezone
-from django.utils.translation import ugettext as _
-from misago.acl.utils import ACLError403, ACLError404
-from misago.forms import Form, FormLayout, FormFields
-from misago.forums.models import Forum
-from misago.markdown import post_markdown
-from misago.messages import Message
-from misago.readstracker.trackers import ThreadsTracker
-from misago.threads.forms import MoveThreadsForm, SplitThreadForm, MovePostsForm, QuickReplyForm
-from misago.threads.models import Thread, Post, Karma, Change, Checkpoint
-from misago.threads.views.base import BaseView
-from misago.views import error403, error404
-from misago.utils import make_pagination, slugify
-from misago.watcher.models import ThreadWatch
-
-class ThreadView(BaseView):
-    def fetch_thread(self, thread):
-        self.thread = Thread.objects.get(pk=thread)
-        self.forum = self.thread.forum
-        self.proxy = Forum.objects.parents_aware_forum(self.forum)
-        self.request.acl.forums.allow_forum_view(self.forum)
-        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
-        self.parents = Forum.objects.forum_parents(self.forum.pk, True)
-        self.tracker = ThreadsTracker(self.request, self.forum)
-        if self.request.user.is_authenticated():
-            try:
-                self.watcher = ThreadWatch.objects.get(user=self.request.user, thread=self.thread)
-            except ThreadWatch.DoesNotExist:
-                pass
-
-    def fetch_posts(self, page):
-        self.count = self.request.acl.threads.filter_posts(self.request, self.thread, Post.objects.filter(thread=self.thread)).count()
-        self.posts = self.request.acl.threads.filter_posts(self.request, self.thread, Post.objects.filter(thread=self.thread)).prefetch_related('checkpoint_set', 'user', 'user__rank')
-        if self.thread.merges > 0:
-            self.posts = self.posts.order_by('merge', 'pk')
-        else:
-            self.posts = self.posts.order_by('pk')
-        self.pagination = make_pagination(page, self.count, self.request.settings.posts_per_page)
-        if self.request.settings.posts_per_page < self.count:
-            self.posts = self.posts[self.pagination['start']:self.pagination['stop']]
-        self.read_date = self.tracker.get_read_date(self.thread)
-        ignored_users = []
-        if self.request.user.is_authenticated():
-            ignored_users = self.request.user.ignored_users()
-        posts_dict = {}
-        for post in self.posts:
-            posts_dict[post.pk] = post
-            post.message = self.request.messages.get_message('threads_%s' % post.pk)
-            post.is_read = post.date <= self.read_date or (post.pk != self.thread.start_post_id and post.moderated)
-            post.karma_vote = None
-            post.ignored = self.thread.start_post_id != post.pk and not self.thread.pk in self.request.session.get('unignore_threads', []) and post.user_id in ignored_users
-            if post.ignored:
-                self.ignored = True
-        last_post = self.posts[len(self.posts) - 1]
-        if not self.tracker.is_read(self.thread):
-            self.tracker.set_read(self.thread, last_post)
-            self.tracker.sync()
-        if self.watcher and last_post.date > self.watcher.last_read:
-            self.watcher.last_read = timezone.now()
-            self.watcher.save(force_update=True)
-        if self.request.user.is_authenticated():
-            for karma in Karma.objects.filter(post_id__in=posts_dict.keys()).filter(user=self.request.user):
-                posts_dict[karma.post_id].karma_vote = karma
-
-    def get_post_actions(self):
-        acl = self.request.acl.threads.get_role(self.thread.forum_id)
-        actions = []
-        try:
-            if acl['can_approve'] and self.thread.replies_moderated > 0:
-                actions.append(('accept', _('Accept posts')))
-            if acl['can_move_threads_posts']:
-                actions.append(('merge', _('Merge posts into one')))
-                actions.append(('split', _('Split posts to new thread')))
-                actions.append(('move', _('Move posts to other thread')))
-            if acl['can_protect_posts']:
-                actions.append(('protect', _('Protect posts')))
-                actions.append(('unprotect', _('Remove posts protection')))
-            if acl['can_delete_posts']:
-                if self.thread.replies_deleted > 0:
-                    actions.append(('undelete', _('Undelete posts')))
-                actions.append(('soft', _('Soft delete posts')))
-            if acl['can_delete_posts'] == 2:
-                actions.append(('hard', _('Hard delete posts')))
-        except KeyError:
-            pass
-        return actions
-
-    def make_posts_form(self):
-        self.posts_form = None
-        list_choices = self.get_post_actions();
-        if (not self.request.user.is_authenticated()
-            or not list_choices):
-            return
-
-        form_fields = {}
-        form_fields['list_action'] = forms.ChoiceField(choices=list_choices)
-        list_choices = []
-        for item in self.posts:
-            list_choices.append((item.pk, None))
-        if not list_choices:
-            return
-        form_fields['list_items'] = forms.MultipleChoiceField(choices=list_choices, widget=forms.CheckboxSelectMultiple)
-        self.posts_form = type('PostsViewForm', (Form,), form_fields)
-
-    def handle_posts_form(self):
-        if self.request.method == 'POST' and self.request.POST.get('origin') == 'posts_form':
-            self.posts_form = self.posts_form(self.request.POST, request=self.request)
-            if self.posts_form.is_valid():
-                checked_items = []
-                for post in self.posts:
-                    if str(post.pk) in self.posts_form.cleaned_data['list_items']:
-                        checked_items.append(post.pk)
-                if checked_items:
-                    form_action = getattr(self, 'post_action_' + self.posts_form.cleaned_data['list_action'])
-                    try:
-                        response = form_action(checked_items)
-                        if response:
-                            return response
-                        return redirect(self.request.path)
-                    except forms.ValidationError as e:
-                        self.message = Message(e.messages[0], 'error')
-                else:
-                    self.message = Message(_("You have to select at least one post."), 'error')
-            else:
-                if 'list_action' in self.posts_form.errors:
-                    self.message = Message(_("Action requested is incorrect."), 'error')
-                else:
-                    self.message = Message(posts_form.non_field_errors()[0], 'error')
-        else:
-            self.posts_form = self.posts_form(request=self.request)
-
-    def post_action_accept(self, ids):
-        accepted = 0
-        for post in self.posts:
-            if post.pk in ids and post.moderated:
-                accepted += 1
-        if accepted:
-            self.thread.post_set.filter(id__in=ids).update(moderated=False)
-            self.thread.sync()
-            self.thread.save(force_update=True)
-            self.request.messages.set_flash(Message(_('Selected posts have been accepted and made visible to other members.')), 'success', 'threads')
-
-    def post_action_merge(self, ids):
-        users = []
-        posts = []
-        for post in self.posts:
-            if post.pk in ids:
-                posts.append(post)
-                if not post.user_id in users:
-                    users.append(post.user_id)
-                if len(users) > 1:
-                    raise forms.ValidationError(_("You cannot merge replies made by different members!"))
-        if len(posts) < 2:
-            raise forms.ValidationError(_("You have to select two or more posts you want to merge."))
-        new_post = posts[0]
-        for post in posts[1:]:
-            post.merge_with(new_post)
-            post.delete()
-        md, new_post.post_preparsed = post_markdown(self.request, new_post.post)
-        new_post.save(force_update=True)
-        self.thread.sync()
-        self.thread.save(force_update=True)
-        self.forum.sync()
-        self.forum.save(force_update=True)
-        self.request.messages.set_flash(Message(_('Selected posts have been merged into one message.')), 'success', 'threads')
-
-    def post_action_split(self, ids):
-        for id in ids:
-            if id == self.thread.start_post_id:
-                raise forms.ValidationError(_("You cannot split first post from thread."))
-        message = None
-        if self.request.POST.get('do') == 'split':
-            form = SplitThreadForm(self.request.POST, request=self.request)
-            if form.is_valid():
-                new_thread = Thread()
-                new_thread.forum = form.cleaned_data['thread_forum']
-                new_thread.name = form.cleaned_data['thread_name']
-                new_thread.slug = slugify(form.cleaned_data['thread_name'])
-                new_thread.start = timezone.now()
-                new_thread.last = timezone.now()
-                new_thread.start_poster_name = 'n'
-                new_thread.start_poster_slug = 'n'
-                new_thread.last_poster_name = 'n'
-                new_thread.last_poster_slug = 'n'
-                new_thread.save(force_insert=True)
-                prev_merge = -1
-                merge = -1
-                for post in self.posts:
-                    if post.pk in ids:
-                        if prev_merge != post.merge:
-                            prev_merge = post.merge
-                            merge += 1
-                        post.merge = merge
-                        post.move_to(new_thread)
-                        post.save(force_update=True)
-                new_thread.sync()
-                new_thread.save(force_update=True)
-                self.thread.sync()
-                self.thread.save(force_update=True)
-                self.forum.sync()
-                self.forum.save(force_update=True)
-                if new_thread.forum != self.forum:
-                    new_thread.forum.sync()
-                    new_thread.forum.save(force_update=True)
-                self.request.messages.set_flash(Message(_("Selected posts have been split to new thread.")), 'success', 'threads')
-                return redirect(reverse('thread', kwargs={'thread': new_thread.pk, 'slug': new_thread.slug}))
-            message = Message(form.non_field_errors()[0], 'error')
-        else:
-            form = SplitThreadForm(request=self.request, initial={
-                                                                  'thread_name': _('[Split] %s') % self.thread.name,
-                                                                  'thread_forum': self.forum,
-                                                                  })
-        return self.request.theme.render_to_response('threads/split.html',
-                                                     {
-                                                      'message': message,
-                                                      'forum': self.forum,
-                                                      'parents': self.parents,
-                                                      'thread': self.thread,
-                                                      'posts': ids,
-                                                      'form': FormLayout(form),
-                                                      },
-                                                     context_instance=RequestContext(self.request));
-
-    def post_action_move(self, ids):
-        message = None
-        if self.request.POST.get('do') == 'move':
-            form = MovePostsForm(self.request.POST, request=self.request, thread=self.thread)
-            if form.is_valid():
-                thread = form.cleaned_data['thread_url']
-                prev_merge = -1
-                merge = -1
-                for post in self.posts:
-                    if post.pk in ids:
-                        if prev_merge != post.merge:
-                            prev_merge = post.merge
-                            merge += 1
-                        post.merge = merge + thread.merges
-                        post.move_to(thread)
-                        post.save(force_update=True)
-                if self.thread.post_set.count() == 0:
-                    self.thread.delete()
-                else:
-                    self.thread.sync()
-                    self.thread.save(force_update=True)
-                thread.sync()
-                thread.save(force_update=True)
-                thread.forum.sync()
-                thread.forum.save(force_update=True)
-                if self.forum.pk != thread.forum.pk:
-                    self.forum.sync()
-                    self.forum.save(force_update=True)
-                self.request.messages.set_flash(Message(_("Selected posts have been moved to new thread.")), 'success', 'threads')
-                return redirect(reverse('thread', kwargs={'thread': thread.pk, 'slug': thread.slug}))
-            message = Message(form.non_field_errors()[0], 'error')
-        else:
-            form = MovePostsForm(request=self.request)
-        return self.request.theme.render_to_response('threads/move_posts.html',
-                                                     {
-                                                      'message': message,
-                                                      'forum': self.forum,
-                                                      'parents': self.parents,
-                                                      'thread': self.thread,
-                                                      'posts': ids,
-                                                      'form': FormLayout(form),
-                                                      },
-                                                     context_instance=RequestContext(self.request));
-
-    def post_action_undelete(self, ids):
-        undeleted = []
-        for post in self.posts:
-            if post.pk in ids and post.deleted:
-                undeleted.append(post.pk)
-        if undeleted:
-            self.thread.post_set.filter(id__in=undeleted).update(deleted=False)
-            self.thread.sync()
-            self.thread.save(force_update=True)
-            self.forum.sync()
-            self.forum.save(force_update=True)
-            self.request.messages.set_flash(Message(_('Selected posts have been restored.')), 'success', 'threads')
-
-    def post_action_protect(self, ids):
-        protected = 0
-        for post in self.posts:
-            if post.pk in ids and not post.protected:
-                protected += 1
-        if protected:
-            self.thread.post_set.filter(id__in=ids).update(protected=True)
-            self.request.messages.set_flash(Message(_('Selected posts have been protected from edition.')), 'success', 'threads')
-
-    def post_action_unprotect(self, ids):
-        unprotected = 0
-        for post in self.posts:
-            if post.pk in ids and post.protected:
-                unprotected += 1
-        if unprotected:
-            self.thread.post_set.filter(id__in=ids).update(protected=False)
-            self.request.messages.set_flash(Message(_('Protection from editions has been removed from selected posts.')), 'success', 'threads')
-
-    def post_action_soft(self, ids):
-        deleted = []
-        for post in self.posts:
-            if post.pk in ids and not post.deleted:
-                if post.pk == self.thread.start_post_id:
-                    raise forms.ValidationError(_("You cannot delete first post of thread using this action. If you want to delete thread, use thread moderation instead."))
-                deleted.append(post.pk)
-        if deleted:
-            self.thread.post_set.filter(id__in=deleted).update(deleted=True)
-            self.thread.sync()
-            self.thread.save(force_update=True)
-            self.forum.sync()
-            self.forum.save(force_update=True)
-            self.request.messages.set_flash(Message(_('Selected posts have been deleted.')), 'success', 'threads')
-
-    def post_action_hard(self, ids):
-        deleted = []
-        for post in self.posts:
-            if post.pk in ids:
-                if post.pk == self.thread.start_post_id:
-                    raise forms.ValidationError(_("You cannot delete first post of thread using this action. If you want to delete thread, use thread moderation instead."))
-                deleted.append(post.pk)
-        if deleted:
-            for post in self.posts:
-                if post.pk in deleted:
-                    post.delete()
-            self.thread.sync()
-            self.thread.save(force_update=True)
-            self.forum.sync()
-            self.forum.save(force_update=True)
-            self.request.messages.set_flash(Message(_('Selected posts have been deleted.')), 'success', 'threads')
-
-    def get_thread_actions(self):
-        acl = self.request.acl.threads.get_role(self.thread.forum_id)
-        actions = []
-        try:
-            if acl['can_approve'] and self.thread.moderated:
-                actions.append(('accept', _('Accept this thread')))
-            if acl['can_pin_threads'] == 2 and self.thread.weight < 2:
-                actions.append(('annouce', _('Change this thread to annoucement')))
-            if acl['can_pin_threads'] > 0 and self.thread.weight != 1:
-                actions.append(('sticky', _('Change this thread to sticky')))
-            if acl['can_pin_threads'] > 0:
-                if self.thread.weight == 2:
-                    actions.append(('normal', _('Change this thread to normal')))
-                if self.thread.weight == 1:
-                    actions.append(('normal', _('Unpin this thread')))
-            if acl['can_move_threads_posts']:
-                actions.append(('move', _('Move this thread')))
-            if acl['can_close_threads']:
-                if self.thread.closed:
-                    actions.append(('open', _('Open this thread')))
-                else:
-                    actions.append(('close', _('Close this thread')))
-            if acl['can_delete_threads']:
-                if self.thread.deleted:
-                    actions.append(('undelete', _('Undelete this thread')))
-                else:
-                    actions.append(('soft', _('Soft delete this thread')))
-            if acl['can_delete_threads'] == 2:
-                actions.append(('hard', _('Hard delete this thread')))
-        except KeyError:
-            pass
-        return actions
-
-    def make_thread_form(self):
-        self.thread_form = None
-        list_choices = self.get_thread_actions();
-        if (not self.request.user.is_authenticated()
-            or not list_choices):
-            return
-        form_fields = {'thread_action': forms.ChoiceField(choices=list_choices)}
-        self.thread_form = type('ThreadViewForm', (Form,), form_fields)
-
-    def handle_thread_form(self):
-        if self.request.method == 'POST' and self.request.POST.get('origin') == 'thread_form':
-            self.thread_form = self.thread_form(self.request.POST, request=self.request)
-            if self.thread_form.is_valid():
-                form_action = getattr(self, 'thread_action_' + self.thread_form.cleaned_data['thread_action'])
-                try:
-                    response = form_action()
-                    if response:
-                        return response
-                    return redirect(self.request.path)
-                except forms.ValidationError as e:
-                    self.message = Message(e.messages[0], 'error')
-            else:
-                if 'thread_action' in self.thread_form.errors:
-                    self.message = Message(_("Action requested is incorrect."), 'error')
-                else:
-                    self.message = Message(form.non_field_errors()[0], 'error')
-        else:
-            self.thread_form = self.thread_form(request=self.request)
-
-    def thread_action_accept(self):
-        # Sync thread and post
-        self.thread.moderated = False
-        self.thread.replies_moderated -= 1
-        self.thread.save(force_update=True)
-        self.thread.start_post.moderated = False
-        self.thread.start_post.save(force_update=True)
-        self.thread.last_post.set_checkpoint(self.request, 'accepted')
-        # Sync user
-        if self.thread.last_post.user:
-            self.thread.start_post.user.threads += 1
-            self.thread.start_post.user.posts += 1
-            self.thread.start_post.user.save(force_update=True)
-        # Sync forum
-        self.forum.sync()
-        self.forum.save(force_update=True)
-        # Update monitor
-        self.request.monitor['threads'] = int(self.request.monitor['threads']) + 1
-        self.request.monitor['posts'] = int(self.request.monitor['posts']) + self.thread.replies + 1
-        self.request.messages.set_flash(Message(_('Thread has been marked as reviewed and made visible to other members.')), 'success', 'threads')
-
-    def thread_action_annouce(self):
-        self.thread.weight = 2
-        self.thread.save(force_update=True)
-        self.request.messages.set_flash(Message(_('Thread has been turned into annoucement.')), 'success', 'threads')
-
-    def thread_action_sticky(self):
-        self.thread.weight = 1
-        self.thread.save(force_update=True)
-        self.request.messages.set_flash(Message(_('Thread has been turned into sticky.')), 'success', 'threads')
-
-    def thread_action_normal(self):
-        self.thread.weight = 0
-        self.thread.save(force_update=True)
-        self.request.messages.set_flash(Message(_('Thread weight has been changed to normal.')), 'success', 'threads')
-
-    def thread_action_move(self):
-        message = None
-        if self.request.POST.get('do') == 'move':
-            form = MoveThreadsForm(self.request.POST, request=self.request, forum=self.forum)
-            if form.is_valid():
-                new_forum = form.cleaned_data['new_forum']
-                self.thread.move_to(new_forum)
-                self.thread.save(force_update=True)
-                self.forum.sync()
-                self.forum.save(force_update=True)
-                new_forum.sync()
-                new_forum.save(force_update=True)
-                self.request.messages.set_flash(Message(_('Thread has been moved to "%(forum)s".') % {'forum': new_forum.name}), 'success', 'threads')
-                return None
-            message = Message(form.non_field_errors()[0], 'error')
-        else:
-            form = MoveThreadsForm(request=self.request, forum=self.forum)
-        return self.request.theme.render_to_response('threads/move_thread.html',
-                                                     {
-                                                      'message': message,
-                                                      'forum': self.forum,
-                                                      'parents': self.parents,
-                                                      'thread': self.thread,
-                                                      'form': FormLayout(form),
-                                                      },
-                                                     context_instance=RequestContext(self.request));
-
-    def thread_action_open(self):
-        self.thread.closed = False
-        self.thread.save(force_update=True)
-        self.thread.last_post.set_checkpoint(self.request, 'opened')
-        self.request.messages.set_flash(Message(_('Thread has been opened.')), 'success', 'threads')
-
-    def thread_action_close(self):
-        self.thread.closed = True
-        self.thread.save(force_update=True)
-        self.thread.last_post.set_checkpoint(self.request, 'closed')
-        self.request.messages.set_flash(Message(_('Thread has been closed.')), 'success', 'threads')
-
-    def thread_action_undelete(self):
-        # Update thread
-        self.thread.deleted = False
-        self.thread.replies_deleted -= 1
-        self.thread.save(force_update=True)
-        # Update first post in thread
-        self.thread.start_post.deleted = False
-        self.thread.start_post.save(force_update=True)
-        # Set checkpoint
-        self.thread.last_post.set_checkpoint(self.request, 'undeleted')
-        # Update forum
-        self.forum.sync()
-        self.forum.save(force_update=True)
-        # Update monitor
-        self.request.monitor['threads'] = int(self.request.monitor['threads']) + 1
-        self.request.monitor['posts'] = int(self.request.monitor['posts']) + self.thread.replies + 1
-        self.request.messages.set_flash(Message(_('Thread has been undeleted.')), 'success', 'threads')
-
-    def thread_action_soft(self):
-        # Update thread
-        self.thread.deleted = True
-        self.thread.replies_deleted += 1
-        self.thread.save(force_update=True)
-        # Update first post in thread
-        self.thread.start_post.deleted = True
-        self.thread.start_post.save(force_update=True)
-        # Set checkpoint
-        self.thread.last_post.set_checkpoint(self.request, 'deleted')
-        # Update forum
-        self.forum.sync()
-        self.forum.save(force_update=True)
-        # Update monitor
-        self.request.monitor['threads'] = int(self.request.monitor['threads']) - 1
-        self.request.monitor['posts'] = int(self.request.monitor['posts']) - self.thread.replies - 1
-        self.request.messages.set_flash(Message(_('Thread has been deleted.')), 'success', 'threads')
-
-    def thread_action_hard(self):
-        # Delete thread
-        self.thread.delete()
-        # Update forum
-        self.forum.sync()
-        self.forum.save(force_update=True)
-        # Update monitor
-        self.request.monitor['threads'] = int(self.request.monitor['threads']) - 1
-        self.request.monitor['posts'] = int(self.request.monitor['posts']) - self.thread.replies - 1
-        self.request.messages.set_flash(Message(_('Thread "%(thread)s" has been deleted.') % {'thread': self.thread.name}), 'success', 'threads')
-        return redirect(reverse('forum', kwargs={'forum': self.forum.pk, 'slug': self.forum.slug}))
-
-    def __call__(self, request, slug=None, thread=None, page=0):
-        self.request = request
-        self.pagination = None
-        self.parents = None
-        self.ignored = False
-        self.watcher = None
-        try:
-            self.fetch_thread(thread)
-            self.fetch_posts(page)
-            self.message = request.messages.get_message('threads')
-            self.make_thread_form()
-            if self.thread_form:
-                response = self.handle_thread_form()
-                if response:
-                    return response
-            self.make_posts_form()
-            if self.posts_form:
-                response = self.handle_posts_form()
-                if response:
-                    return response
-        except Thread.DoesNotExist:
-            return error404(self.request)
-        except ACLError403 as e:
-            return error403(request, e.message)
-        except ACLError404 as e:
-            return error404(request, e.message)
-        # Merge proxy into forum
-        self.forum.closed = self.proxy.closed
-        return request.theme.render_to_response('threads/thread.html',
-                                                {
-                                                 'message': self.message,
-                                                 'forum': self.forum,
-                                                 'parents': self.parents,
-                                                 'thread': self.thread,
-                                                 'is_read': self.tracker.is_read(self.thread),
-                                                 'count': self.count,
-                                                 'posts': self.posts,
-                                                 'ignored_posts': self.ignored,
-                                                 'watcher': self.watcher,
-                                                 'pagination': self.pagination,
-                                                 'quick_reply': FormFields(QuickReplyForm(request=request)).fields,
-                                                 'thread_form': FormFields(self.thread_form).fields if self.thread_form else None,
-                                                 'posts_form': FormFields(self.posts_form).fields if self.posts_form else None,
-                                                 },
-                                                context_instance=RequestContext(request));

+ 0 - 0
misago/tos/__init__.py


+ 29 - 23
misago/urls.py

@@ -4,29 +4,35 @@ from django.contrib.staticfiles.urls import staticfiles_urlpatterns
 from misago.admin import ADMIN_PATH, site
 
 # Include frontend patterns
-urlpatterns = patterns('',
-    (r'^', include('misago.authn.urls')),
-    (r'^users/', include('misago.profiles.urls')),
-    (r'^usercp/', include('misago.usercp.urls')),
-    (r'^register/', include('misago.register.urls')),
-    (r'^activate/', include('misago.activation.urls')),
-    (r'^reset-password/', include('misago.resetpswd.urls')),
-    (r'^', include('misago.threads.urls')),
-    (r'^', include('misago.watcher.urls')),
-    url(r'^category/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', 'misago.views.category', name="category"),
-    url(r'^redirect/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', 'misago.views.redirection', name="redirect"),
-    url(r'^$', 'misago.views.home', name="index"),
-    url(r'^alerts/$', 'misago.alerts.views.show_alerts', name="alerts"),
-    url(r'^news/$', 'misago.newsfeed.views.newsfeed', name="newsfeed"),
-    url(r'^tos/$', 'misago.tos.views.forum_tos', name="tos"),
-    url(r'^read/$', 'misago.views.read_all', name="read_all"),
-    url(r'^forum-map/$', 'misago.views.forum_map', name="forum_map"),
-    url(r'^popular/$', 'misago.views.popular_threads', name="popular_threads"),
-    url(r'^popular/(?P<page>[0-9]+)/$', 'misago.views.popular_threads', name="popular_threads"),
-    url(r'^new/$', 'misago.views.new_threads', name="new_threads"),
-    url(r'^new/(?P<page>[0-9]+)/$', 'misago.views.new_threads', name="new_threads"),
+urlpatterns = patterns('misago.apps',
+    url(r'^$', 'index.index', name="index"),
+    url(r'^read-all/$', 'readall.read_all', name="read_all"),
+    url(r'^register/$', 'register.views.form', name="register"),
+    url(r'^category/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', 'category.category', name="category"),
+    url(r'^redirect/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', 'redirect.redirect', name="redirect"),
+    url(r'^alerts/$', 'alerts.alerts', name="alerts"),
+    url(r'^news/$', 'newsfeed.newsfeed', name="newsfeed"),
+    url(r'^tos/$', 'tos.tos', name="tos"),
+    url(r'^forum-map/$', 'forummap.forum_map', name="forum_map"),
+    url(r'^popular/$', 'popularthreads.popular_threads', name="popular_threads"),
+    url(r'^popular/(?P<page>[0-9]+)/$', 'popularthreads.popular_threads', name="popular_threads"),
+    url(r'^new/$', 'newthreads.new_threads', name="new_threads"),
+    url(r'^new/(?P<page>[0-9]+)/$', 'newthreads.new_threads', name="new_threads"),
 )
 
+urlpatterns += patterns('',
+    (r'^', include('misago.apps.signin.urls')),
+    (r'^users/', include('misago.apps.profiles.urls')),
+    (r'^usercp/', include('misago.apps.usercp.urls')),
+    (r'^activate/', include('misago.apps.activation.urls')),
+    (r'^watched-threads/', include('misago.apps.watchedthreads.urls')),
+    (r'^reset-password/', include('misago.apps.resetpswd.urls')),
+    (r'^private-threads/', include('misago.apps.privatethreads.urls')),
+    #(r'^reports/', include('misago.apps.reports.urls')),
+    (r'^', include('misago.apps.threads.urls')),
+)
+
+
 # Include admin patterns
 if ADMIN_PATH:
     urlpatterns += patterns('',
@@ -40,5 +46,5 @@ if settings.DEBUG:
     )
 
 # Set error handlers
-handler403 = 'misago.views.error403'
-handler404 = 'misago.views.error404'
+handler403 = 'misago.apps.errors.error403'
+handler404 = 'misago.apps.errors.error404'

+ 0 - 0
misago/usercp/__init__.py


+ 0 - 0
misago/usercp/avatar/__init__.py


+ 0 - 0
misago/usercp/credentials/__init__.py


+ 0 - 117
misago/usercp/migrations/0001_initial.py

@@ -1,117 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'UsernameChange'
-        db.create_table(u'usercp_usernamechange', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='namechanges', to=orm['users.User'])),
-            ('date', self.gf('django.db.models.fields.DateTimeField')()),
-            ('old_username', self.gf('django.db.models.fields.CharField')(max_length=255)),
-        ))
-        db.send_create_signal(u'usercp', ['UsernameChange'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'UsernameChange'
-        db.delete_table(u'usercp_usernamechange')
-
-
-    models = {
-        u'ranks.rank': {
-            'Meta': {'object_name': 'Rank'},
-            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'roles.role': {
-            'Meta': {'object_name': 'Role'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'usercp.usernamechange': {
-            'Meta': {'object_name': 'UsernameChange'},
-            'date': ('django.db.models.fields.DateTimeField', [], {}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'old_username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'namechanges'", 'to': u"orm['users.User']"})
-        },
-        u'users.user': {
-            'Meta': {'object_name': 'User'},
-            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'allow_pms': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
-            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
-            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
-            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
-            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ranks.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['roles.Role']", 'symmetrical': 'False'}),
-            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
-            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        }
-    }
-
-    complete_apps = ['usercp']

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


+ 0 - 0
misago/usercp/options/__init__.py


+ 0 - 42
misago/usercp/options/forms.py

@@ -1,42 +0,0 @@
-from django import forms
-from django.utils.translation import ugettext_lazy as _
-from misago.timezones import tzlist
-from misago.forms import Form
-
-
-class UserForumOptionsForm(Form):
-    newsletters = forms.BooleanField(required=False)
-    timezone = forms.ChoiceField(choices=tzlist())
-    hide_activity = forms.ChoiceField(choices=(
-                                               (0, _("Show my presence to everyone")),
-                                               (1, _("Show my presence to people I follow")),
-                                               (2, _("Show my presence to nobody")),
-                                               ))
-    subscribe_start = forms.ChoiceField(choices=(
-                                                 (0, _("Don't watch")),
-                                                 (1, _("Put on watched threads list")),
-                                                 (2, _("Put on watched threads list and e-mail me when somebody replies")),
-                                                 ))
-    subscribe_reply = forms.ChoiceField(choices=(
-                                                 (0, _("Don't watch")),
-                                                 (1, _("Put on watched threads list")),
-                                                 (2, _("Put on watched threads list and e-mail me when somebody replies")),
-                                                 ))
-
-    layout = (
-              (
-               _("Forum Options"),
-               (
-                ('hide_activity', {'label': _("Your Visibility"), 'help_text': _("If you want to, you can limit other members ability to track your presence on forums.")}),
-                ('timezone', {'label': _("Your Current Timezone"), 'help_text': _("If dates and hours displayed by forums are inaccurate, you can fix it by adjusting timezone setting.")}),
-                ('newsletters', {'label': _("Newsletters"), 'help_text': _("On occasion board administrator may want to send e-mail message to multiple members."), 'inline': _("Yes, I want to subscribe forum newsletter")}),
-                )
-               ),
-              (
-               _("Watching Threads"),
-               (
-                ('subscribe_start', {'label': _("Threads I start")}),
-                ('subscribe_reply', {'label': _("Threads I reply to")}),
-                )
-               ),
-              )

+ 0 - 0
misago/usercp/signature/__init__.py


+ 0 - 0
misago/usercp/username/__init__.py


+ 0 - 0
misago/users/__init__.py


+ 0 - 7
misago/users/context_processors.py

@@ -1,7 +0,0 @@
-def user(request):
-    try:
-        return {
-            'user': request.user,
-        }
-    except AttributeError:
-        pass

+ 0 - 13
misago/users/fixtures.py

@@ -1,13 +0,0 @@
-from misago.monitor.fixtures import load_monitor_fixture
-
-monitor_fixtures = {
-                  'users': 0,
-                  'users_inactive': 0,
-                  'users_reported': 0,
-                  'last_user': None,
-                  'last_user_name': None,
-                  'last_user_slug': None,
-                  }
-
-def load_fixtures():
-    load_monitor_fixture(monitor_fixtures)

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


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


+ 0 - 192
misago/users/migrations/0001_initial.py

@@ -1,192 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'User'
-        db.create_table(u'users_user', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('username', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('username_slug', self.gf('django.db.models.fields.SlugField')(unique=True, max_length=255)),
-            ('email', self.gf('django.db.models.fields.EmailField')(max_length=255)),
-            ('email_hash', self.gf('django.db.models.fields.CharField')(unique=True, max_length=32)),
-            ('password', self.gf('django.db.models.fields.CharField')(max_length=255)),
-            ('password_date', self.gf('django.db.models.fields.DateTimeField')()),
-            ('avatar_type', self.gf('django.db.models.fields.CharField')(max_length=10, null=True, blank=True)),
-            ('avatar_image', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('avatar_original', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('avatar_temp', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('signature', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('signature_preparsed', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('join_date', self.gf('django.db.models.fields.DateTimeField')()),
-            ('join_ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
-            ('join_agent', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('last_date', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
-            ('last_ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39, null=True, blank=True)),
-            ('last_agent', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('hide_activity', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('allow_pms', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('subscribe_start', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('subscribe_reply', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('receive_newsletters', self.gf('django.db.models.fields.BooleanField')(default=True)),
-            ('threads', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('posts', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('votes', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('karma_given_p', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('karma_given_n', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('karma_p', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('karma_n', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('following', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('followers', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('score', self.gf('django.db.models.fields.IntegerField')(default=0)),
-            ('ranking', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('rank', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ranks.Rank'], null=True, on_delete=models.SET_NULL, blank=True)),
-            ('last_sync', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
-            ('title', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
-            ('last_post', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
-            ('last_search', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
-            ('alerts', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
-            ('alerts_date', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
-            ('activation', self.gf('django.db.models.fields.IntegerField')(default=0)),
-            ('token', self.gf('django.db.models.fields.CharField')(max_length=12, null=True, blank=True)),
-            ('avatar_ban', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('avatar_ban_reason_user', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('avatar_ban_reason_admin', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('signature_ban', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('signature_ban_reason_user', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('signature_ban_reason_admin', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
-            ('timezone', self.gf('django.db.models.fields.CharField')(default='utc', max_length=255)),
-            ('is_team', self.gf('django.db.models.fields.BooleanField')(default=False)),
-            ('acl_key', self.gf('django.db.models.fields.CharField')(max_length=12, null=True, blank=True)),
-        ))
-        db.send_create_signal(u'users', ['User'])
-
-        # Adding M2M table for field follows on 'User'
-        db.create_table(u'users_user_follows', (
-            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
-            ('from_user', models.ForeignKey(orm[u'users.user'], null=False)),
-            ('to_user', models.ForeignKey(orm[u'users.user'], null=False))
-        ))
-        db.create_unique(u'users_user_follows', ['from_user_id', 'to_user_id'])
-
-        # Adding M2M table for field ignores on 'User'
-        db.create_table(u'users_user_ignores', (
-            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
-            ('from_user', models.ForeignKey(orm[u'users.user'], null=False)),
-            ('to_user', models.ForeignKey(orm[u'users.user'], null=False))
-        ))
-        db.create_unique(u'users_user_ignores', ['from_user_id', 'to_user_id'])
-
-        # Adding M2M table for field roles on 'User'
-        db.create_table(u'users_user_roles', (
-            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
-            ('user', models.ForeignKey(orm[u'users.user'], null=False)),
-            ('role', models.ForeignKey(orm[u'roles.role'], null=False))
-        ))
-        db.create_unique(u'users_user_roles', ['user_id', 'role_id'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'User'
-        db.delete_table(u'users_user')
-
-        # Removing M2M table for field follows on 'User'
-        db.delete_table('users_user_follows')
-
-        # Removing M2M table for field ignores on 'User'
-        db.delete_table('users_user_ignores')
-
-        # Removing M2M table for field roles on 'User'
-        db.delete_table('users_user_roles')
-
-
-    models = {
-        u'ranks.rank': {
-            'Meta': {'object_name': 'Rank'},
-            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'roles.role': {
-            'Meta': {'object_name': 'Role'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'users.user': {
-            'Meta': {'object_name': 'User'},
-            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'allow_pms': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
-            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
-            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
-            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
-            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ranks.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['roles.Role']", 'symmetrical': 'False'}),
-            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
-            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        }
-    }
-
-    complete_apps = ['users']

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


+ 0 - 4
misago/users/signals.py

@@ -1,4 +0,0 @@
-import django.dispatch
-
-delete_user_content = django.dispatch.Signal()
-rename_user = django.dispatch.Signal()

+ 0 - 94
misago/utils/__init__.py

@@ -1,94 +0,0 @@
-"""
-Smart slugify
-"""
-import django.template.defaultfilters
-
-use_unidecode = True
-try:
-    from unidecode import unidecode
-except ImportError:
-    use_unidecode = False
-
-def slugify(string):
-    if use_unidecode:
-        string = unidecode(string)
-    return django.template.defaultfilters.slugify(string)
-
-
-"""
-Lazy translate that allows us to access original message
-"""
-from django.utils import translation
-
-def ugettext_lazy(str):
-    t = translation.ugettext_lazy(str)
-    t.message = str
-    return t
-def get_msgid(gettext):
-    try:
-        return gettext.message
-    except AttributeError:
-        return None
-
-
-"""
-Random string
-"""
-from django.utils import crypto
-
-def get_random_string(length):
-    return crypto.get_random_string(length, "1234567890qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM")
-
-
-"""
-Date formats
-"""
-from django.utils.formats import get_format
-
-formats = {
-    'DATE_FORMAT': '',
-    'DATETIME_FORMAT': '',
-    'TIME_FORMAT': '',
-    'YEAR_MONTH_FORMAT': '',
-    'MONTH_DAY_FORMAT': '',
-    'SHORT_DATE_FORMAT': '',
-    'SHORT_DATETIME_FORMAT': '',
-}
-
-for key in formats:
-    formats[key] = get_format(key).replace('P', 'g:i a')
-
-
-"""
-Build pagination list
-"""
-import math
-
-def make_pagination(page, total, max):
-    pagination = {'start': 0, 'stop': 0, 'prev':-1, 'next':-1}
-    page = int(page)
-    if page > 0:
-        pagination['start'] = (page - 1) * max
-
-    # Set page and total stat
-    pagination['page'] = int(pagination['start'] / max) + 1
-    pagination['total'] = int(math.ceil(total / float(max)))
-
-    # Fix too large offset
-    if pagination['start'] > total:
-        pagination['start'] = 0
-
-    # Allow prev/next?
-    if total > max:
-        if pagination['page'] > 1:
-            pagination['prev'] = pagination['page'] - 1
-        if pagination['page'] < pagination['total']:
-            pagination['next'] = pagination['page'] + 1
-
-    # Fix empty pagers
-    if not pagination['total']:
-        pagination['total'] = 1
-
-    # Set stop offset
-    pagination['stop'] = pagination['start'] + max
-    return pagination

+ 27 - 86
misago/template/templatetags/django2jinja.py → misago/utils/datesformats.py

@@ -1,78 +1,25 @@
 import math
-import urllib
-from coffin.template import Library
-from django.conf import settings
-from misago.utils import slugify
-
-register = Library()
-
-@register.object(name='widthratio')
-def widthratio(min=0, max=100, range=100):
-    return int(math.ceil(float(float(min) / float(max) * int(range))))
-
-
-@register.object(name='query')
-def query_string(**kwargs):
-    query = urllib.urlencode(kwargs)
-    return '?%s' % (query if kwargs else '')
-
-@register.filter(name='low')
-def low(value):
-    if not value:
-        return u''
-    try:
-        rest = value[1:]
-    except IndexError:
-        rest = ''
-    return '%s%s' % (unicode(value[0]).lower(), rest)
-
-
-@register.filter(name="slugify")
-def slugify_function(format_string):
-    return slugify(format_string)
-
-
-"""
-Markdown filters
-"""
-@register.filter(name='markdown')
-def parse_markdown(value, format=None):
-    import markdown
-    if not format:
-        format = settings.OUTPUT_FORMAT
-    return markdown.markdown(value, safe_mode='escape', output_format=format).strip()
-
-@register.filter(name='markdown_short')
-def short_markdown(value, length=300):
-    from misago.markdown.factory import clear_markdown
-    value = clear_markdown(value)
-    if len(value) <= length:
-        return ' '.join(value.splitlines())
-    value = ' '.join(value.splitlines())
-    value = value[0:length]
-    while value[-1] != ' ':
-        value = value[0:-1]
-    value = value.strip()
-    if value[-3:3] != '...':
-        value = '%s...' % value
-    return value
-
-
-@register.filter(name='markdown_final')
-def finalize_markdown(value):
-    from misago.markdown.factory import finalize_markdown
-    return finalize_markdown(value)
-
-
-"""
-Date and time filters
-"""
 from datetime import datetime, timedelta
 from django.utils.dateformat import format, time_format
+from django.utils.formats import get_format
 from django.utils.timezone import is_aware, localtime, utc
 from django.utils.translation import pgettext, ungettext, ugettext as _
-from misago.utils import slugify, formats
-
+from misago.utils.strings import slugify
+
+# Build date formats
+formats = {
+    'DATE_FORMAT': '',
+    'DATETIME_FORMAT': '',
+    'TIME_FORMAT': '',
+    'YEAR_MONTH_FORMAT': '',
+    'MONTH_DAY_FORMAT': '',
+    'SHORT_DATE_FORMAT': '',
+    'SHORT_DATETIME_FORMAT': '',
+}
+
+for key in formats:
+    formats[key] = get_format(key).replace('P', 'g:i a')
+    
 def date(val, arg=""):
     if not val:
         return _("Never")
@@ -94,19 +41,22 @@ def reldate(val, arg=""):
     # Today check
     if format(local, 'Y-z') == format(local_now, 'Y-z'):
         return _("Today, %(hour)s") % {'hour': time_format(local, formats['TIME_FORMAT'])}
+        
     # Yesteday check
     yesterday = localtime(now - timedelta(days=1))
     if format(local, 'Y-z') == format(yesterday, 'Y-z'):
         return _("Yesterday, %(hour)s") % {'hour': time_format(local, formats['TIME_FORMAT'])}
+        
     # Tomorrow Check
     tomorrow = localtime(now + timedelta(days=1))
     if format(local, 'Y-z') == format(tomorrow, 'Y-z'):
         return _("Tomorrow, %(hour)s") % {'hour': time_format(local, formats['TIME_FORMAT'])}
+        
     # Day of Week check
     if format(local, 'D') != format(local_now, 'D') and diff.days > -7 and diff.days < 7:
         return _("%(day)s, %(hour)s") % {'day': format(local, 'l'), 'hour': time_format(local, formats['TIME_FORMAT'])}
 
-    # Fallback to custom      
+    # Fallback to date      
     return date(val, arg)
 
 
@@ -125,17 +75,21 @@ def reltimesince(val, arg=""):
     if diff.seconds >= 0:
         if diff.seconds <= 5:
             return _("Just now")
+            
         if diff.seconds <= 60:
             return _("Minute ago")
+            
         if diff.seconds < 3600:
             minutes = int(math.floor(diff.seconds / 60.0))
             return ungettext(
                     "Minute ago",
                     "%(minutes)s minutes ago",
                 minutes) % {'minutes': minutes}
+                
         if diff.seconds < 10800:
             hours = int(math.floor(diff.seconds / 3600.0))
             minutes = (diff.seconds - (hours * 3600)) / 60
+            
             if minutes > 0:
                 return ungettext(
                     "Hour and %(minutes)s ago",
@@ -145,24 +99,11 @@ def reltimesince(val, arg=""):
                         "%(minutes)s minutes",
                     minutes) % {'minutes': minutes}}
                 return _("%(hours)s hours and %(minutes)s minutes ago") % {'hours': hours, 'minutes': minutes}
+                
             return ungettext(
                     "Hour ago",
                     "%(hours)s hours ago",
                 hours) % {'hours': hours}
 
     # Fallback to reldate
-    return reldate(val, arg)
-
-@register.filter(name='date')
-def date_filter(val, arg=""):
-    return date(val, arg)
-
-
-@register.filter(name='reldate')
-def reldate_filter(val, arg=""):
-    return reldate(val, arg)
-
-
-@register.filter(name='reltimesince')
-def reltimesince_filter(val, arg=""):
-    return reltimesince(val, arg)
+    return reldate(val, arg)

+ 109 - 0
misago/utils/fixtures.py

@@ -0,0 +1,109 @@
+import base64
+from django.utils import timezone
+from django.utils.importlib import import_module
+from misago.models import MonitorItem, SettingsGroup, Setting
+from misago.utils.translation import get_msgid
+try:
+    import cPickle as pickle
+except ImportError:
+    import pickle
+
+def load_fixture(name):
+    """
+    Load fixture
+    """
+    try:
+        fixture = import_module(name)
+        fixture.load()
+        return True
+    except (ImportError, AttributeError):
+        return False
+
+
+def update_fixture(name):
+    """
+    If fixture module contains update function, use it to update fixture
+    """
+    try:
+        fixture = import_module(name)
+        fixture.update()
+        return True
+    except (ImportError, AttributeError):
+        return False
+
+
+def load_settings_group_fixture(group, fixture):
+    model_group = SettingsGroup(
+                                key=group,
+                                name=get_msgid(fixture['name']),
+                                description=get_msgid(fixture.get('description'))
+                                )
+    model_group.save(force_insert=True)
+    fixture = fixture.get('settings', ())
+    position = 0
+    for setting in fixture:
+        value = setting[1].get('value')
+        value_default = setting[1].get('default')
+        # Convert boolean True and False to 1 and 0, otherwhise it wont work
+        if setting[1].get('type') == 'boolean':
+            value = 1 if value else 0
+            value_default = 1 if value_default else 0
+        # Convert array value to string
+        if setting[1].get('type') == 'array':
+            value = ','.join(value) if value else ''
+            value_default = ','.join(value_default) if value_default else ''
+        # Store setting in database
+        model_setting = Setting(
+                                setting=setting[0],
+                                group=model_group,
+                                value=value,
+                                value_default=value_default,
+                                normalize_to=setting[1].get('type'),
+                                field=setting[1].get('input'),
+                                extra=base64.encodestring(pickle.dumps(setting[1].get('extra', {}), pickle.HIGHEST_PROTOCOL)),
+                                position=position,
+                                separator=get_msgid(setting[1].get('separator')),
+                                name=get_msgid(setting[1].get('name')),
+                                description=get_msgid(setting[1].get('description')),
+                                )
+        model_setting.save(force_insert=True)
+        position += 1
+
+
+def update_settings_group_fixture(group, fixture):
+    try:
+        model_group = SettingsGroup.objects.get(key=group)
+        settings = {}
+        for setting in model_group.setting_set.all():
+            settings[setting.pk] = setting.value
+        model_group.delete()
+        load_settings_group_fixture(group, fixture)
+
+        for setting in settings:
+            try:
+                new_setting = Setting.objects.get(pk=setting)
+                new_setting.value = settings[setting]
+                new_setting.save(force_update=True)
+            except Setting.DoesNotExist:
+                pass
+    except SettingsGroup.DoesNotExist:
+        load_settings_group_fixture(group, fixture)
+
+
+def load_settings_fixture(fixture):
+    for group in fixture:
+        load_settings_group_fixture(group[0], group[1])
+
+
+def update_settings_fixture(fixture):
+    for group in fixture:
+        update_settings_group_fixture(group[0], group[1])
+
+
+def load_monitor_fixture(fixture):
+    for id in fixture.keys():
+        item = MonitorItem.objects.create(
+                                          id=id,
+                                          value=fixture[id],
+                                          updated=timezone.now()
+                                          )

+ 30 - 0
misago/utils/pagination.py

@@ -0,0 +1,30 @@
+import math
+
+def make_pagination(page, total, max):
+    pagination = {'start': 0, 'stop': 0, 'prev':-1, 'next':-1}
+    page = int(page)
+    if page > 0:
+        pagination['start'] = (page - 1) * max
+
+    # Set page and total stat
+    pagination['page'] = int(pagination['start'] / max) + 1
+    pagination['total'] = int(math.ceil(total / float(max)))
+
+    # Fix too large offset
+    if pagination['start'] > total:
+        pagination['start'] = 0
+
+    # Allow prev/next?
+    if total > max:
+        if pagination['page'] > 1:
+            pagination['prev'] = pagination['page'] - 1
+        if pagination['page'] < pagination['total']:
+            pagination['next'] = pagination['page'] + 1
+
+    # Fix empty pagers
+    if not pagination['total']:
+        pagination['total'] = 1
+
+    # Set stop offset
+    pagination['stop'] = pagination['start'] + max
+    return pagination

+ 31 - 0
misago/utils/strings.py

@@ -0,0 +1,31 @@
+from django.template.defaultfilters import slugify as django_slugify
+from django.utils import crypto
+try:
+    from unidecode import unidecode
+    use_unidecode = True
+except ImportError:
+    use_unidecode = False
+
+def slugify(string):
+    if use_unidecode:
+        string = unidecode(string)
+    return django_slugify(string)
+
+
+def random_string(length):
+    return crypto.get_random_string(length, "1234567890qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM")
+
+
+def short_string(string, length=16):
+    if len(string) <= length:
+        return string;
+
+    short = []
+    length = length - 3
+    string = string[0:length]
+    bits = string.split()
+    if len(bits[-1]) > length:
+        bits[-1] = bits[-1][0:length]
+    if len(bits[-1]) < 3:
+        bits.pop()
+    return '%s...' % (' '.join(bits))

+ 0 - 0
misago/timezones/__init__.py → misago/utils/timezones.py


+ 20 - 0
misago/utils/translation.py

@@ -0,0 +1,20 @@
+from django.utils import translation
+
+def ugettext_lazy(string):
+    """
+    Custom wrapper that preserves untranslated message on lazy translation string object
+    """
+    t = translation.ugettext_lazy(string)
+    t.message = string
+    return t
+
+
+def get_msgid(gettext):
+    """
+    Function for extracting untranslated message from lazy translation string object
+    made trough ugettext_lazy
+    """
+    try:
+        return gettext.message
+    except AttributeError:
+        return None

+ 0 - 15
misago/utils/validators.py

@@ -1,15 +0,0 @@
-from django.core.exceptions import ValidationError
-from django.utils.translation import ugettext_lazy as _
-from misago.utils import slugify
-
-class validate_sluggable(object):
-    def __init__(self, error_short=None, error_long=None):
-        self.error_short = error_short if error_short else _("Value has to contain alpha-numerical characters.")
-        self.error_long = error_long if error_long else _("Value is too long.")
-
-    def __call__(self, value):
-        slug = slugify(value)
-        if not slug:
-            raise ValidationError(self.error_short)
-        if len(slug) > 255:
-            raise ValidationError(self.error_long)

+ 22 - 0
misago/utils/views.py

@@ -0,0 +1,22 @@
+from json import dumps as json_dumps
+from django.core.urlresolvers import reverse
+from django.http import HttpResponse
+from django.shortcuts import redirect
+from django.template import RequestContext
+
+def redirect_message(request, message, type='info', owner=None):
+    request.messages.set_flash(message, type, owner)
+    return redirect(reverse('index'))
+
+
+def json_response(request, json={}, status=200, message=None):
+    json.update({'code': status, 'message': unicode(message)})
+    response = json_dumps(json, sort_keys=True,  ensure_ascii=False)
+    return HttpResponse(response, content_type='application/json', status=status)
+
+
+def ajax_response(request, template=None, macro=None, vars={}, json={}, status=200, message=None):
+    html = ''
+    if macro:
+        html = request.theme.macro(template, macro, vars, context_instance=RequestContext(request));
+    return json_response(request, json.update({'html': html}), status, message)

+ 23 - 4
misago/users/validators.py → misago/validators.py

@@ -2,11 +2,25 @@ import re
 from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.utils.translation import ungettext, ugettext_lazy as _
-from misago.banning.models import check_ban
-from misago.settings.settings import Settings as DBSettings
+from misago.models import Ban
+from misago.utils.strings import slugify
+
+class validate_sluggable(object):
+    def __init__(self, error_short=None, error_long=None):
+        self.error_short = error_short if error_short else _("Value has to contain alpha-numerical characters.")
+        self.error_long = error_long if error_long else _("Value is too long.")
+
+    def __call__(self, value):
+        slug = slugify(value)
+        if not slug:
+            raise ValidationError(self.error_short)
+        if len(slug) > 255:
+            raise ValidationError(self.error_long)
+
 
 def validate_username(value, db_settings):
     value = unicode(value).strip()
+
     if len(value) < db_settings['username_length_min']:
         raise ValidationError(ungettext(
             'Username must be at least one character long.',
@@ -15,6 +29,7 @@ def validate_username(value, db_settings):
         ) % {
             'count': db_settings['username_length_min'],
         })
+
     if len(value) > db_settings['username_length_max']:
         raise ValidationError(ungettext(
             'Username cannot be longer than one characters.',
@@ -23,18 +38,21 @@ def validate_username(value, db_settings):
         ) % {
             'count': db_settings['username_length_max'],
         })
+
     if settings.UNICODE_USERNAMES:
         if not re.search('^[^\W_]+$', value, re.UNICODE):
             raise ValidationError(_("Username can only contain letters and digits."))
     else:
         if not re.search('^[^\W_]+$', value):
             raise ValidationError(_("Username can only contain latin alphabet letters and digits."))
-    if check_ban(username=value):
+    
+    if Ban.objects.check_ban(username=value):
         raise ValidationError(_("This username is forbidden."))
 
 
 def validate_password(value, db_settings):
     value = unicode(value).strip()
+
     if len(value) < db_settings['password_length']:
         raise ValidationError(ungettext(
             'Correct password has to be at least one character long.',
@@ -43,6 +61,7 @@ def validate_password(value, db_settings):
         ) % {
             'count': db_settings['password_length'],
         })
+
     for test in db_settings['password_complexity']:
         if test in ('case', 'digits', 'special'):
             if not re.search('[a-zA-Z]', value):
@@ -60,5 +79,5 @@ def validate_password(value, db_settings):
 
 def validate_email(value):
     value = unicode(value).strip()
-    if check_ban(email=value):
+    if Ban.objects.check_ban(email=value):
         raise ValidationError(_("This board forbids registrations using this e-mail address."))

+ 0 - 203
misago/views.py

@@ -1,203 +0,0 @@
-from datetime import timedelta
-from django.core.cache import cache
-from django.core.urlresolvers import reverse
-from django.shortcuts import redirect
-from django.template import RequestContext
-from django.utils import timezone
-from django.utils.translation import ugettext as _
-from misago.authn.decorators import block_guest
-from misago.csrf.decorators import check_csrf
-from misago.forums.models import Forum
-from misago.messages import Message
-from misago.readstracker.models import ForumRecord, ThreadRecord
-from misago.readstracker.trackers import ForumsTracker
-from misago.ranks.models import Rank
-from misago.sessions.models import Session
-from misago.threads.models import Thread, Post
-from misago.utils import make_pagination
-
-def home(request):
-    # Threads ranking
-    popular_threads = []
-    if request.settings['thread_ranking_size'] > 0:
-        popular_threads = cache.get('thread_ranking_%s' % request.user.make_acl_key(), 'nada')
-        if popular_threads == 'nada':
-            popular_threads = []
-            for thread in Thread.objects.filter(moderated=False).filter(deleted=False).filter(forum__in=request.acl.threads.get_readable_forums(request.acl)).prefetch_related('forum').order_by('-score')[:request.settings['thread_ranking_size']]:
-                thread.forum_name = thread.forum.name
-                thread.forum_slug = thread.forum.slug
-                popular_threads.append(thread)
-            cache.set('thread_ranking_%s' % request.user.make_acl_key(), popular_threads, 60 * request.settings['thread_ranking_refresh'])
-
-    # Ranks online
-    ranks_list = cache.get('ranks_online', 'nada')
-    if ranks_list == 'nada':
-        ranks_dict = {}
-        ranks_list = []
-        users_list = []
-        for rank in Rank.objects.filter(on_index=True).order_by('order'):
-            rank_entry = {'id':rank.id, 'name': rank.name, 'style': rank.style, 'title': rank.title, 'online': []}
-            ranks_list.append(rank_entry)
-            ranks_dict[rank.pk] = rank_entry
-        if ranks_dict:
-            for session in Session.objects.select_related('user').filter(rank__in=ranks_dict.keys()).filter(last__gte=timezone.now() - timedelta(minutes=10)).filter(user__isnull=False):
-                if not session.user_id in users_list:
-                    ranks_dict[session.user.rank_id]['online'].append(session.user)
-                    users_list.append(session.user_id)
-            # Assert we are on list
-            if (request.user.is_authenticated() and request.user.rank_id in ranks_dict.keys()
-                and not request.user.id in users_list):
-                    ranks_dict[request.user.rank_id]['online'].append(request.user)
-            del ranks_dict
-            del users_list
-        cache.set('ranks_online', ranks_list, 300)
-
-    # Users online
-    users_online = cache.get('users_online', 'nada')
-    if users_online == 'nada':
-        users_online = Session.objects.filter(matched=True).filter(crawler__isnull=True).filter(last__gte=timezone.now() - timedelta(seconds=300)).count()
-        cache.set('users_online', users_online, 300)
-    if not users_online and not request.user.is_crawler():
-        # Cheatey trick to make sure we'll never display
-        # zero users online to human client
-        users_online = 1
-
-    # Load reads tracker and build forums list
-    reads_tracker = ForumsTracker(request.user)
-    forums_list = Forum.objects.treelist(request.acl.forums, tracker=reads_tracker)
-    
-    # Whitelist ignored members
-    Forum.objects.ignored_users(request.user, forums_list)
-        
-    # Render page 
-    return request.theme.render_to_response('index.html',
-                                            {
-                                             'forums_list': forums_list,
-                                             'ranks_online': ranks_list,
-                                             'users_online': users_online,
-                                             'popular_threads': popular_threads,
-                                             },
-                                            context_instance=RequestContext(request));
-
-
-def category(request, forum, slug):
-    if not request.acl.forums.can_see(forum):
-        return error404(request)
-    try:
-        forum = Forum.objects.get(pk=forum, type='category')
-        if not request.acl.forums.can_browse(forum):
-            return error403(request, _("You don't have permission to browse this category."))
-    except Forum.DoesNotExist:
-        return error404(request)
-
-    forum.subforums = Forum.objects.treelist(request.acl.forums, forum, tracker=ForumsTracker(request.user))
-    return request.theme.render_to_response('category.html',
-                                            {
-                                             'category': forum,
-                                             'parents': Forum.objects.forum_parents(forum.pk),
-                                             },
-                                            context_instance=RequestContext(request));
-
-
-def redirection(request, forum, slug):
-    if not request.acl.forums.can_see(forum):
-        return error404(request)
-    try:
-        forum = Forum.objects.get(pk=forum, type='redirect')
-        if not request.acl.forums.can_browse(forum):
-            return error403(request, _("You don't have permission to follow this redirect."))
-        redirects_tracker = request.session.get('redirects', [])
-        if forum.pk not in redirects_tracker:
-            redirects_tracker.append(forum.pk)
-            request.session['redirects'] = redirects_tracker
-            forum.redirects += 1
-            forum.save(force_update=True)
-        return redirect(forum.redirect)
-    except Forum.DoesNotExist:
-        return error404(request)
-
-
-@block_guest
-@check_csrf
-def read_all(request):
-    ForumRecord.objects.filter(user=request.user).delete()
-    ThreadRecord.objects.filter(user=request.user).delete()
-    now = timezone.now()
-    bulk = []
-    for forum in request.acl.forums.known_forums():
-        new_record = ForumRecord(user=request.user, forum_id=forum, updated=now, cleared=now)
-        bulk.append(new_record)
-    if bulk:
-        ForumRecord.objects.bulk_create(bulk)
-    request.messages.set_flash(Message(_("All forums have been marked as read.")), 'success')
-    return redirect(reverse('index'))
-
-
-def forum_map(request):
-    return request.theme.render_to_response('forum_map.html',
-                                            {
-                                             'ranks': Rank.objects.filter(as_tab=1).order_by('order'),
-                                             'forums': Forum.objects.treelist(request.acl.forums),
-                                             },
-                                            context_instance=RequestContext(request));
-
-
-def popular_threads(request, page=0):
-    queryset = Thread.objects.filter(forum_id__in=request.acl.threads.get_readable_forums(request.acl)).filter(deleted=False).filter(moderated=False)
-    items_total = queryset.count();
-    pagination = make_pagination(page, items_total, 30)
-
-    queryset = queryset.order_by('-score').prefetch_related('forum')[pagination['start']:pagination['stop']];
-    if request.settings['avatars_on_threads_list']:
-        queryset = queryset.prefetch_related('start_poster', 'last_poster')
-
-    return request.theme.render_to_response('popular_threads.html',
-                                            {
-                                             'items_total': items_total,
-                                             'threads': Thread.objects.with_reads(queryset, request.user),
-                                             'pagination': pagination,
-                                             },
-                                            context_instance=RequestContext(request));
-
-
-def new_threads(request, page=0):
-    queryset = Thread.objects.filter(forum_id__in=request.acl.threads.get_readable_forums(request.acl)).filter(deleted=False).filter(moderated=False).filter(start__gte=(timezone.now() - timedelta(days=2)))
-    items_total = queryset.count();
-    pagination = make_pagination(page, items_total, 30)
-
-    queryset = queryset.order_by('-start').prefetch_related('forum')[pagination['start']:pagination['stop']];
-    if request.settings['avatars_on_threads_list']:
-        queryset = queryset.prefetch_related('start_poster', 'last_poster')
-
-    return request.theme.render_to_response('new_threads.html',
-                                            {
-                                             'items_total': items_total,
-                                             'threads': Thread.objects.with_reads(queryset, request.user),
-                                             'pagination': pagination,
-                                             },
-                                            context_instance=RequestContext(request));
-
-
-def redirect_message(request, message, type='info', owner=None):
-    request.messages.set_flash(message, type, owner)
-    return redirect(reverse('index'))
-
-
-def error403(request, message=None):
-    return error_view(request, 403, message)
-
-
-def error404(request, message=None):
-    return error_view(request, 404, message)
-
-
-def error_view(request, error, message):
-    response = request.theme.render_to_response(('error%s.html' % error),
-                                                {
-                                                 'message': message,
-                                                 'hide_signin': True,
-                                                 'exception_response': True,
-                                                 },
-                                                context_instance=RequestContext(request));
-    response.status_code = error
-    return response

+ 0 - 0
misago/watcher/__init__.py


+ 0 - 217
misago/watcher/migrations/0001_initial.py

@@ -1,217 +0,0 @@
-# -*- coding: utf-8 -*-
-import datetime
-from south.db import db
-from south.v2 import SchemaMigration
-from django.db import models
-
-
-class Migration(SchemaMigration):
-
-    def forwards(self, orm):
-        # Adding model 'ThreadWatch'
-        db.create_table(u'watcher_threadwatch', (
-            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
-            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['users.User'])),
-            ('forum', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['forums.Forum'])),
-            ('thread', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['threads.Thread'])),
-            ('last_read', self.gf('django.db.models.fields.DateTimeField')()),
-            ('email', self.gf('django.db.models.fields.BooleanField')(default=False)),
-        ))
-        db.send_create_signal(u'watcher', ['ThreadWatch'])
-
-
-    def backwards(self, orm):
-        # Deleting model 'ThreadWatch'
-        db.delete_table(u'watcher_threadwatch')
-
-
-    models = {
-        u'forums.forum': {
-            'Meta': {'object_name': 'Forum'},
-            'attrs': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'description_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_thread': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Thread']"}),
-            'last_thread_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_thread_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_thread_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'parent': ('mptt.fields.TreeForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': u"orm['forums.Forum']"}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'posts_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'prune_last': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'prune_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'redirect': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'redirects': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'redirects_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'show_details': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'type': ('django.db.models.fields.CharField', [], {'max_length': '12'})
-        },
-        u'ranks.rank': {
-            'Meta': {'object_name': 'Rank'},
-            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'roles.role': {
-            'Meta': {'object_name': 'Role'},
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
-        },
-        u'threads.post': {
-            'Meta': {'object_name': 'Post'},
-            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'checkpoints': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'date': ('django.db.models.fields.DateTimeField', [], {}),
-            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'edit_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'edit_reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edit_user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'edit_user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edit_user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'edits': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'mentions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'mention_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'merge': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'post': ('django.db.models.fields.TextField', [], {}),
-            'post_preparsed': ('django.db.models.fields.TextField', [], {}),
-            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'reported': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Thread']"}),
-            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
-        },
-        u'threads.thread': {
-            'Meta': {'object_name': 'Thread'},
-            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last': ('django.db.models.fields.DateTimeField', [], {}),
-            'last_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Post']"}),
-            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['users.User']"}),
-            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'merges': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'replies': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_deleted': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_moderated': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'replies_reported': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'score': ('django.db.models.fields.PositiveIntegerField', [], {'default': '30'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'start': ('django.db.models.fields.DateTimeField', [], {}),
-            'start_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['threads.Post']"}),
-            'start_poster': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'start_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'start_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
-            'start_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'type': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'weight': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        },
-        u'users.user': {
-            'Meta': {'object_name': 'User'},
-            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'allow_pms': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
-            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
-            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
-            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': u"orm['users.User']"}),
-            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
-            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
-            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
-            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
-            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ranks.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
-            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['roles.Role']", 'symmetrical': 'False'}),
-            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
-            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
-            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
-            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
-            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
-            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
-        },
-        u'watcher.threadwatch': {
-            'Meta': {'object_name': 'ThreadWatch'},
-            'email': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['forums.Forum']"}),
-            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'last_read': ('django.db.models.fields.DateTimeField', [], {}),
-            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['threads.Thread']"}),
-            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['users.User']"})
-        }
-    }
-
-    complete_apps = ['watcher']

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


+ 0 - 33
misago/watcher/models.py

@@ -1,33 +0,0 @@
-from django.db import models
-from misago.forums.signals import move_forum_content
-from misago.threads.signals import move_thread, merge_thread
-
-class ThreadWatch(models.Model):
-    user = models.ForeignKey('users.User')
-    forum = models.ForeignKey('forums.Forum')
-    thread = models.ForeignKey('threads.Thread')
-    last_read = models.DateTimeField()
-    email = models.BooleanField(default=False)
-    deleted = False
-    
-    def save(self, *args, **kwargs):
-        if not self.deleted:
-            super(ThreadWatch, self).save(*args, **kwargs)
-            
-
-def move_forum_content_handler(sender, **kwargs):
-    ThreadWatch.objects.filter(forum=sender).update(forum=kwargs['move_to'])
-
-move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_threads_watchers")
-
-
-def move_thread_handler(sender, **kwargs):
-    ThreadWatch.objects.filter(forum=sender.forum_id).update(forum=kwargs['move_to'])
-
-move_thread.connect(move_thread_handler, dispatch_uid="move_thread_watchers")
-
-
-def merge_thread_handler(sender, **kwargs):
-    ThreadWatch.objects.filter(thread=sender).delete()
-
-merge_thread.connect(merge_thread_handler, dispatch_uid="merge_threads_watchers")

+ 0 - 8
misago/watcher/urls.py

@@ -1,8 +0,0 @@
-from django.conf.urls import patterns, url
-
-urlpatterns = patterns('misago.watcher.views',
-    url(r'^watched/$', 'watched_threads', name="watched_threads"),
-    url(r'^watched/(?P<page>\d+)/$', 'watched_threads', name="watched_threads"),
-    url(r'^watched/new/$', 'watched_threads', name="watched_threads_new", kwargs={'new': True}),
-    url(r'^watched/new/(?P<page>\d+)/$', 'watched_threads', name="watched_threads_new", kwargs={'new': True}),
-)

+ 12 - 0
requirements.txt

@@ -0,0 +1,12 @@
+django
+django-debug-toolbar
+django-mptt
+coffin
+jinja2
+markdown
+path.py
+Pillow
+pytz
+recaptcha-client
+South
+Unidecode

+ 64 - 38
static/cranefly/css/cranefly.css

@@ -852,12 +852,16 @@ a.label:hover,a.label:focus,a.badge:hover,a.badge:focus{color:#ffffff;text-decor
 .header-primary .breadcrumb li a:hover,.header-primary .breadcrumb li a:active{color:#333333;}
 .header-primary .breadcrumb li .divider{padding-left:0px;padding-right:0px;}.header-primary .breadcrumb li .divider i{opacity:0.2;filter:alpha(opacity=20);position:relative;bottom:1px;}
 .header-primary h1{color:#555555;font-size:35px;font-weight:normal;}
-.header-primary .header-stats{overflow:auto;margin-bottom:0px;color:#999999;}.header-primary .header-stats li{float:left;margin-right:14px;}.header-primary .header-stats li>a{color:#999999;}.header-primary .header-stats li>a:hover,.header-primary .header-stats li>a:active{color:#333333;}
+.header-primary .header-stats{overflow:visible;margin-bottom:0px;color:#999999;}.header-primary .header-stats li{float:left;margin-right:14px;}.header-primary .header-stats li>a{color:#999999;}.header-primary .header-stats li>a:hover,.header-primary .header-stats li>a:active{color:#333333;}
 .header-primary .header-stats li>i{opacity:0.5;filter:alpha(opacity=50);}
+.header-primary .header-stats li.stats-form{float:right;}.header-primary .header-stats li.stats-form form{margin:0px;margin-bottom:-12px;padding:0px;}.header-primary .header-stats li.stats-form form button{position:relative;bottom:12px;}.header-primary .header-stats li.stats-form form button>i{position:relative;top:0px;}
 .header-primary .header-tabs{border-bottom:0px;margin:0px;margin-top:-10px;position:relative;top:9px;}.header-primary .header-tabs li a:link,.header-primary .header-tabs li a:visited{background:none;border:none;border-radius:0px;margin-bottom:4px;padding:6.666666666666667px 10px;color:#888888;font-weight:bold;}
 .header-primary .header-tabs li a:hover,.header-primary .header-tabs li a:active,.header-primary .header-tabs li a a:focus{background:none;border-bottom:4px solid #555555;margin-bottom:0px;color:#555555;}
 .header-primary .header-tabs li.active a:link,.header-primary .header-tabs li.active a:visited,.header-primary .header-tabs li.active a:hover,.header-primary .header-tabs li.active a:active{background:none;border-bottom:4px solid #cf402e;margin-bottom:0px;color:#333333;}
 .header-primary .header-tabs li .form-inline{margin:0px;margin-left:14px;margin-bottom:7px;}.header-primary .header-tabs li .form-inline .btn-icon{padding-left:7px;padding-right:7px;}
+.header-primary .header-tabs li .form-inline i{position:relative;top:0px;}
+.header-primary .header-tabs li a.btn{border:1px solid #cccccc;*border:0;border-radius:3px;margin:0px;padding:4px 12px;color:#333333;}.header-primary .header-tabs li a.btn i{position:relative;top:0px;}
+.header-primary .header-tabs li a.btn:visited,.header-primary .header-tabs li a.btn:hover{background-color:#ffffff;border-color:#a6a6a6;}
 html,body{height:100%;}
 #wrap{min-height:100%;height:auto !important;height:100%;margin:0 auto -100px;}#wrap .container-primary{padding-top:20px;padding-bottom:120px;}#wrap .container-primary .page-description{margin-bottom:20px;}
 #wrap .container-primary hr{border:none;border-top:1px solid #eeeeee;}
@@ -865,22 +869,28 @@ footer{background-color:#eeeeee;border-top:1px solid #dadada;height:80px;padding
 footer .credits{color:#555555;font-size:90%;}footer .credits a:link,footer .credits a:active,footer .credits a:visited,footer .credits a:hover{color:#555555;}
 ::selection{background:#f89406;color:#ffffff;}
 ::-moz-selection{background:#f89406;color:#ffffff;}
-.navbar .navbar-inner{background:none;background-color:#f3f3f3;border-bottom:1px solid #dfdfdf;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}.navbar .navbar-inner .container{background:url("../img/logo.png");background-position:left center;background-repeat:no-repeat;}
-.navbar .navbar-inner .brand{margin-left:6px;text-shadow:none;}.navbar .navbar-inner .brand:link,.navbar .navbar-inner .brand:active,.navbar .navbar-inner .brand:visited,.navbar .navbar-inner .brand:hover{color:#c24a3b;font-size:200%;}.navbar .navbar-inner .brand:link span,.navbar .navbar-inner .brand:active span,.navbar .navbar-inner .brand:visited span,.navbar .navbar-inner .brand:hover span{color:#c0c0c0;}
-.navbar .navbar-inner .navbar-search-form{background-color:#ffffff;border:1px solid #dfdfdf;border-radius:3px;margin-top:9px;padding:1px 0px;color:#333333;}.navbar .navbar-inner .navbar-search-form input{border:none;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;margin:0px;}
-.navbar .navbar-inner .navbar-search-form button{margin:0px;opacity:0.3;filter:alpha(opacity=30);}.navbar .navbar-inner .navbar-search-form button:hover,.navbar .navbar-inner .navbar-search-form button:active{opacity:0.8;filter:alpha(opacity=80);}
-.navbar .navbar-inner .navbar-blocks{margin-left:6px;}.navbar .navbar-inner .navbar-blocks li{margin-left:6px;}.navbar .navbar-inner .navbar-blocks li form{margin:0px;padding:0px;}
-.navbar .navbar-inner .navbar-blocks li a:link,.navbar .navbar-inner .navbar-blocks li a:visited,.navbar .navbar-inner .navbar-blocks li .btn-link{background-color:#f8f8f8;border:1px solid #dadada;border-radius:3px;padding:5px 8px;margin-top:9px;}.navbar .navbar-inner .navbar-blocks li a:link i,.navbar .navbar-inner .navbar-blocks li a:visited i,.navbar .navbar-inner .navbar-blocks li .btn-link i{opacity:0.7;filter:alpha(opacity=70);}
-.navbar .navbar-inner .navbar-blocks li a:link .label,.navbar .navbar-inner .navbar-blocks li a:visited .label,.navbar .navbar-inner .navbar-blocks li .btn-link .label{background-color:#cf402e;margin-left:4px;padding-left:6px;padding-right:5px;position:relative;bottom:1px;}
-.navbar .navbar-inner .navbar-blocks li a:hover,.navbar .navbar-inner .navbar-blocks li a:active,.navbar .navbar-inner .navbar-blocks li .btn-link:hover,.navbar .navbar-inner .navbar-blocks li .btn-link:active{background-color:#0088cc;border-color:#006699;}.navbar .navbar-inner .navbar-blocks li a:hover.danger,.navbar .navbar-inner .navbar-blocks li a:active.danger,.navbar .navbar-inner .navbar-blocks li .btn-link:hover.danger,.navbar .navbar-inner .navbar-blocks li .btn-link:active.danger{background-color:#cf402e;border-color:#a53325;}
-.navbar .navbar-inner .navbar-blocks li a:hover.hot,.navbar .navbar-inner .navbar-blocks li a:active.hot,.navbar .navbar-inner .navbar-blocks li .btn-link:hover.hot,.navbar .navbar-inner .navbar-blocks li .btn-link:active.hot{background-color:#f89406;border-color:#c67605;}
-.navbar .navbar-inner .navbar-blocks li a:hover.fresh,.navbar .navbar-inner .navbar-blocks li a:active.fresh,.navbar .navbar-inner .navbar-blocks li .btn-link:hover.fresh,.navbar .navbar-inner .navbar-blocks li .btn-link:active.fresh{background-color:#46a546;border-color:#378137;}
-.navbar .navbar-inner .navbar-blocks li a:hover i,.navbar .navbar-inner .navbar-blocks li a:active i,.navbar .navbar-inner .navbar-blocks li .btn-link:hover i,.navbar .navbar-inner .navbar-blocks li .btn-link:active i{background-image:url("../img/glyphicons-halflings-white.png");opacity:1;filter:alpha(opacity=100);}
-.navbar .navbar-inner .navbar-blocks li a:hover .label,.navbar .navbar-inner .navbar-blocks li a:active .label,.navbar .navbar-inner .navbar-blocks li .btn-link:hover .label,.navbar .navbar-inner .navbar-blocks li .btn-link:active .label{background-color:#eeeeee;color:#333333;}
-.navbar .navbar-inner .navbar-blocks li.user-profile a:link,.navbar .navbar-inner .navbar-blocks li.user-profile a:visited,.navbar .navbar-inner .navbar-blocks li.user-profile a:hover,.navbar .navbar-inner .navbar-blocks li.user-profile a:active{background:none;border:none;margin-right:8px;margin-top:5px;font-weight:bold;text-shadow:none;}.navbar .navbar-inner .navbar-blocks li.user-profile a:link img,.navbar .navbar-inner .navbar-blocks li.user-profile a:visited img,.navbar .navbar-inner .navbar-blocks li.user-profile a:hover img,.navbar .navbar-inner .navbar-blocks li.user-profile a:active img{background-color:#ffffff;border-radius:3px;-webkit-box-shadow:0px 0px 4px #eeeeee;-moz-box-shadow:0px 0px 4px #eeeeee;box-shadow:0px 0px 4px #eeeeee;margin-right:4px;width:32px;height:32px;position:relative;bottom:1px;}
-.navbar .navbar-inner .navbar-user-nav li .btn{padding:2px 8px;margin-left:8px;margin-top:13px;}.navbar .navbar-inner .navbar-user-nav li .btn:link,.navbar .navbar-inner .navbar-user-nav li .btn:active,.navbar .navbar-inner .navbar-user-nav li .btn:hover,.navbar .navbar-inner .navbar-user-nav li .btn:visited{color:#ffffff;}
-.navbar .navbar-inner .navbar-user-nav li .btn.btn-danger{text-shadow:0px 1px 0px #902d20;}.navbar .navbar-inner .navbar-user-nav li .btn.btn-danger:hover,.navbar .navbar-inner .navbar-user-nav li .btn.btn-danger:active,.navbar .navbar-inner .navbar-user-nav li .btn.btn-danger:focus{background-color:#e82c15;border-color:#d12813;}
-.navbar .navbar-inner .navbar-user-nav li .btn.btn-inverse{text-shadow:0px 1px 0px #0d0d0d;}.navbar .navbar-inner .navbar-user-nav li .btn.btn-inverse:hover,.navbar .navbar-inner .navbar-user-nav li .btn.btn-inverse:active,.navbar .navbar-inner .navbar-user-nav li .btn.btn-inverse:focus{background-color:#262626;border-color:#1a1a1a;}
+.navbar-header .navbar-inner{background:none;background-color:#f3f3f3;border-bottom:1px solid #dfdfdf;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}.navbar-header .navbar-inner .container{background:url("../img/logo.png");background-position:left center;background-repeat:no-repeat;}
+.navbar-header .navbar-inner .brand{margin-left:6px;text-shadow:none;}.navbar-header .navbar-inner .brand:link,.navbar-header .navbar-inner .brand:active,.navbar-header .navbar-inner .brand:visited,.navbar-header .navbar-inner .brand:hover{color:#c24a3b;font-size:200%;}.navbar-header .navbar-inner .brand:link span,.navbar-header .navbar-inner .brand:active span,.navbar-header .navbar-inner .brand:visited span,.navbar-header .navbar-inner .brand:hover span{color:#c0c0c0;}
+.navbar-header .navbar-inner .navbar-search-form{background-color:#ffffff;border:1px solid #dfdfdf;border-radius:3px;margin-top:9px;padding:1px 0px;color:#333333;}.navbar-header .navbar-inner .navbar-search-form input{border:none;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;margin:0px;}
+.navbar-header .navbar-inner .navbar-search-form button{margin:0px;opacity:0.3;filter:alpha(opacity=30);}.navbar-header .navbar-inner .navbar-search-form button:hover,.navbar-header .navbar-inner .navbar-search-form button:active{opacity:0.8;filter:alpha(opacity=80);}
+.navbar-header .navbar-inner .navbar-blocks{margin-left:6px;}.navbar-header .navbar-inner .navbar-blocks li{margin-left:6px;}.navbar-header .navbar-inner .navbar-blocks li form{margin:0px;padding:0px;}
+.navbar-header .navbar-inner .navbar-blocks li a:link,.navbar-header .navbar-inner .navbar-blocks li a:visited,.navbar-header .navbar-inner .navbar-blocks li .btn-link{background-color:#f8f8f8;border:1px solid #dadada;border-radius:3px;padding:5px 8px;margin-top:9px;}.navbar-header .navbar-inner .navbar-blocks li a:link i,.navbar-header .navbar-inner .navbar-blocks li a:visited i,.navbar-header .navbar-inner .navbar-blocks li .btn-link i{opacity:0.7;filter:alpha(opacity=70);}
+.navbar-header .navbar-inner .navbar-blocks li a:link .label,.navbar-header .navbar-inner .navbar-blocks li a:visited .label,.navbar-header .navbar-inner .navbar-blocks li .btn-link .label{background-color:#cf402e;margin-left:4px;padding-left:6px;padding-right:5px;position:relative;bottom:1px;}
+.navbar-header .navbar-inner .navbar-blocks li a:hover,.navbar-header .navbar-inner .navbar-blocks li a:active,.navbar-header .navbar-inner .navbar-blocks li .btn-link:hover,.navbar-header .navbar-inner .navbar-blocks li .btn-link:active{background-color:#0088cc;border-color:#006699;}.navbar-header .navbar-inner .navbar-blocks li a:hover.danger,.navbar-header .navbar-inner .navbar-blocks li a:active.danger,.navbar-header .navbar-inner .navbar-blocks li .btn-link:hover.danger,.navbar-header .navbar-inner .navbar-blocks li .btn-link:active.danger{background-color:#cf402e;border-color:#a53325;}
+.navbar-header .navbar-inner .navbar-blocks li a:hover.hot,.navbar-header .navbar-inner .navbar-blocks li a:active.hot,.navbar-header .navbar-inner .navbar-blocks li .btn-link:hover.hot,.navbar-header .navbar-inner .navbar-blocks li .btn-link:active.hot{background-color:#f89406;border-color:#c67605;}
+.navbar-header .navbar-inner .navbar-blocks li a:hover.fresh,.navbar-header .navbar-inner .navbar-blocks li a:active.fresh,.navbar-header .navbar-inner .navbar-blocks li .btn-link:hover.fresh,.navbar-header .navbar-inner .navbar-blocks li .btn-link:active.fresh{background-color:#46a546;border-color:#378137;}
+.navbar-header .navbar-inner .navbar-blocks li a:hover i,.navbar-header .navbar-inner .navbar-blocks li a:active i,.navbar-header .navbar-inner .navbar-blocks li .btn-link:hover i,.navbar-header .navbar-inner .navbar-blocks li .btn-link:active i{background-image:url("../img/glyphicons-halflings-white.png");opacity:1;filter:alpha(opacity=100);}
+.navbar-header .navbar-inner .navbar-blocks li a:hover .label,.navbar-header .navbar-inner .navbar-blocks li a:active .label,.navbar-header .navbar-inner .navbar-blocks li .btn-link:hover .label,.navbar-header .navbar-inner .navbar-blocks li .btn-link:active .label{background-color:#eeeeee;color:#333333;}
+.navbar-header .navbar-inner .navbar-blocks li.user-profile a:link,.navbar-header .navbar-inner .navbar-blocks li.user-profile a:visited,.navbar-header .navbar-inner .navbar-blocks li.user-profile a:hover,.navbar-header .navbar-inner .navbar-blocks li.user-profile a:active{background:none;border:none;margin-right:8px;margin-top:5px;font-weight:bold;text-shadow:none;}.navbar-header .navbar-inner .navbar-blocks li.user-profile a:link img,.navbar-header .navbar-inner .navbar-blocks li.user-profile a:visited img,.navbar-header .navbar-inner .navbar-blocks li.user-profile a:hover img,.navbar-header .navbar-inner .navbar-blocks li.user-profile a:active img{background-color:#ffffff;border-radius:3px;-webkit-box-shadow:0px 0px 4px #eeeeee;-moz-box-shadow:0px 0px 4px #eeeeee;box-shadow:0px 0px 4px #eeeeee;margin-right:4px;width:32px;height:32px;position:relative;bottom:1px;}
+.navbar-header .navbar-inner .navbar-user-nav li .btn{padding:2px 8px;margin-left:8px;margin-top:13px;}.navbar-header .navbar-inner .navbar-user-nav li .btn:link,.navbar-header .navbar-inner .navbar-user-nav li .btn:active,.navbar-header .navbar-inner .navbar-user-nav li .btn:hover,.navbar-header .navbar-inner .navbar-user-nav li .btn:visited{color:#ffffff;}
+.navbar-header .navbar-inner .navbar-user-nav li .btn.btn-danger{text-shadow:0px 1px 0px #902d20;}.navbar-header .navbar-inner .navbar-user-nav li .btn.btn-danger:hover,.navbar-header .navbar-inner .navbar-user-nav li .btn.btn-danger:active,.navbar-header .navbar-inner .navbar-user-nav li .btn.btn-danger:focus{background-color:#e82c15;border-color:#d12813;}
+.navbar-header .navbar-inner .navbar-user-nav li .btn.btn-inverse{text-shadow:0px 1px 0px #0d0d0d;}.navbar-header .navbar-inner .navbar-user-nav li .btn.btn-inverse:hover,.navbar-header .navbar-inner .navbar-user-nav li .btn.btn-inverse:active,.navbar-header .navbar-inner .navbar-user-nav li .btn.btn-inverse:focus{background-color:#262626;border-color:#1a1a1a;}
+.navbar-modbar .navbar-inner{background:none;background-color:#222222;background-image:-webkit-gradient(linear, 0 0, 100% 100%, color-stop(0.25, rgba(0, 0, 0, 0.1)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(0, 0, 0, 0.1)), color-stop(0.75, rgba(0, 0, 0, 0.1)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent);-webkit-background-size:15px 15px;-moz-background-size:15px 15px;background-size:15px 15px;border-bottom:2px dashed #cf402e;min-height:1px;}.navbar-modbar .navbar-inner .nav a:link,.navbar-modbar .navbar-inner .nav a:visited{padding:10.5px 14.7px;color:#b3b3b3;text-shadow:0px 1px 0px #000000;}
+.navbar-modbar .navbar-inner .nav a:hover,.navbar-modbar .navbar-inner .nav a:active{color:#ffffff;}
+.navbar-modbar .navbar-inner .nav li.active a:link,.navbar-modbar .navbar-inner .nav li.active a:visited{color:#ffffff;}
+.navbar-modbar .navbar-inner .navbar-search-form{background-color:#0d0d0d;border-radius:3px;margin-top:6px;padding:1px 0px;}.navbar-modbar .navbar-inner .navbar-search-form input{background-color:#0d0d0d;border:none;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;margin:0px;color:#eeeeee;text-shadow:0px 1px 0px #000000;}
+.navbar-modbar .navbar-inner .navbar-search-form button{margin:0px;opacity:0.6;filter:alpha(opacity=60);}.navbar-modbar .navbar-inner .navbar-search-form button i{background-image:url("../img/glyphicons-halflings-white.png");opacity:1;filter:alpha(opacity=100);}
+.navbar-modbar .navbar-inner .navbar-search-form button:hover,.navbar-modbar .navbar-inner .navbar-search-form button:active{opacity:1;filter:alpha(opacity=100);}
 footer .breadcrumb{background:none;border:none;margin:0px;padding:0px;font-weight:bold;text-shadow:none;}footer .breadcrumb li{text-shadow:none;}footer .breadcrumb li a:link,footer .breadcrumb li a:active,footer .breadcrumb li a:visited,footer .breadcrumb li a:hover{color:#333333;}
 footer .breadcrumb li .divider{opacity:0.3;filter:alpha(opacity=30);margin-left:-6px;}
 footer .breadcrumb li.active{color:#555555;}
@@ -947,21 +957,23 @@ a.btn-link:hover,a.btn-link:active,a.btn-link:focus{opacity:0.9;filter:alpha(opa
 .markdown code{background-color:#333333;border:none;color:#eeeeee;font-size:14px;}
 .markdown pre{background-color:#222222;padding:7px 14px;}.markdown pre code{background:none;border:none;color:#eeeeee;font-size:11.9px;}
 .markdown img{border-radius:3px;box-shadow:0px 0px 4px #555555;margin:10px 0px;}
-.markdown .md-img{overflow:auto;}.markdown .md-img .md-img-span{margin:10px 0px;float:none;}.markdown .md-img .md-img-span .md-img-wrap{background-color:#eeeeee;border:1px solid #ffffff;border-radius:4px;box-shadow:0px 0px 2px #999999;margin:3px;}.markdown .md-img .md-img-span .md-img-wrap .md-img-bg{background-color:#ffffff;border-radius:3px;text-align:center;}.markdown .md-img .md-img-span .md-img-wrap .md-img-bg img{border-radius:3px;box-shadow:none;margin:10px;}
+.markdown .md-img{overflow:auto;}.markdown .md-img .md-img-span{margin:10px 0px;float:none;}.markdown .md-img .md-img-span .md-img-wrap{background-color:#eeeeee;border:1px solid #ffffff;border-radius:4px;box-shadow:0px 0px 2px #999999;margin:3px;}.markdown .md-img .md-img-span .md-img-wrap .md-img-bg{background-color:#ffffff;border-radius:3px;padding:10px;text-align:center;}.markdown .md-img .md-img-span .md-img-wrap .md-img-bg img{border-radius:3px;box-shadow:none;}
 .markdown .md-img .md-img-span .md-img-wrap .md-img-bg .md-img-error{background:url('../img/img_broken.jpg');border-radius:3px;padding:50px 0px;}.markdown .md-img .md-img-span .md-img-wrap .md-img-bg .md-img-error span{background-color:#333333;border-radius:5px;opacity:0.8;filter:alpha(opacity=80);padding:7px 14px;margin:0px auto;color:#ffffff;text-shadow:0px 1px 0px #000000;}
 .markdown .md-img .md-img-span .md-img-wrap .md-img-label{display:block;padding:7px 14px;color:#333333;}.markdown .md-img .md-img-span .md-img-wrap .md-img-label:hover,.markdown .md-img .md-img-span .md-img-wrap .md-img-label:active{color:#333333;text-decoration:none;}
-.markdown pre,.markdown blockquote,.markdown iframe{margin-top:10px;margin-bottom:10px;}.markdown pre>:first-child,.markdown blockquote>:first-child,.markdown iframe>:first-child{margin-top:0px;}
+.markdown pre,.markdown blockquote,.markdown iframe{margin-top:20px;margin-bottom:20px;}.markdown pre>:first-child,.markdown blockquote>:first-child,.markdown iframe>:first-child{margin-top:0px;}
 .markdown pre>:last-child,.markdown blockquote>:last-child,.markdown iframe>:last-child{margin-bottom:0px;}
 .index-sidebar{position:relative;bottom:9px;}
 .index-category{background-color:#ffffff;border:1px solid #d5d5d5;border-radius:2px;-webkit-box-shadow:0px 0px 0px 3px #eeeeee;-moz-box-shadow:0px 0px 0px 3px #eeeeee;box-shadow:0px 0px 0px 3px #eeeeee;margin-bottom:20px;}.index-category table{margin:0px;}.index-category table caption{background-color:#fbfbfb;border:1px solid #d5d5d5;border-radius:2px 2px 0px 0px;margin:-1px;padding:3.966666666666667px 9.9px;color:#333333;font-size:11.9px;font-weight:bold;text-align:left;}.index-category table caption small{margin-left:7px;color:#999999;font-size:11.9px;}
-.index-category table td{padding:11.75px 9.9px;}
+.index-category table td{padding:14.75px 9.9px;}
 .index-category table .forum-icon{padding-right:2.95px;width:1%;}.index-category table .forum-icon .forum-icon-wrap{background-color:#555555;border:1px solid #3b3b3b;border-radius:3px;padding:3px 4px;}.index-category table .forum-icon .forum-icon-wrap.forum-icon-new{background-color:#cf402e;border:1px solid #a53325;}
 .index-category table .forum-icon .forum-icon-wrap.forum-icon-redirect{background-color:#9466c6;border:1px solid #7a43b6;}
 .index-category table .forum-main h3{float:left;margin:0px;padding:0px;font-size:17.5px;font-weight:normal;line-height:20px;}.index-category table .forum-main h3 a:link,.index-category table .forum-main h3 a:visited{color:#333333;}
-.index-category table .forum-main .forum-details{float:right;margin-top:-1.0999999999999996px;color:#999999;font-size:11.9px;}.index-category table .forum-main .forum-details strong,.index-category table .forum-main .forum-details a{color:#555555;font-weight:normal;}
+.index-category table .forum-main .forum-details{background-color:#f7f7f7;border-bottom:1px solid #f0f0f0;border-radius:3px;float:right;margin:-2px 0px;margin-top:-3.0999999999999996px;padding:2px 8px;width:230px;color:#999999;font-size:11.9px;}.index-category table .forum-main .forum-details strong,.index-category table .forum-main .forum-details a{color:#555555;font-weight:normal;}
 .index-category table .forum-main .forum-details a:hover,.index-category table .forum-main .forum-details a:active{color:#333333;}
 .index-category table .forum-main .forum-details strong.stat-increment{color:#46a546;}
-.index-category table .forum-main .forum-description{clear:both;margin:0px;margin-bottom:-2.0999999999999996px;padding:0px;color:#8c8c8c;font-size:11.9px;}
+.index-category table .forum-main .forum-meta-tooltip .tooltip-inner{max-width:400px;text-align:left;}.index-category table .forum-main .forum-meta-tooltip .tooltip-inner .forum-stats{color:#999999;font-size:10.5px;}.index-category table .forum-main .forum-meta-tooltip .tooltip-inner .forum-stats strong{color:#ffffff;}
+.index-category table .forum-main .forum-meta-tooltip .tooltip-inner .forum-stats span{margin-right:14px;}
+.index-category table .forum-main .forum-meta-tooltip .tooltip-inner .forum-description{clear:both;margin:0px;margin-bottom:7px;padding:0px;color:#eeeeee;font-size:14px;}
 .index-category.index-category-important caption{background-color:#cf402e;border:1px solid #a53325;color:#ffffff;text-shadow:0px 1px 0px #672017;}.index-category.index-category-important caption small{color:#280c09;text-shadow:none;}
 .index-category.index-category-inverse caption{background-color:#333333;border:1px solid #1a1a1a;color:#eeeeee;text-shadow:0px 1px 0px #000000;}.index-category.index-category-inverse caption small{color:#b3b3b3;text-shadow:none;}
 .index-category.index-category-info caption{background-color:#3c85a3;border:1px solid #2e677e;color:#ffffff;text-shadow:0px 1px 0px #1a3946;}.index-category.index-category-info caption small{color:#1a3946;text-shadow:none;}
@@ -1014,14 +1026,16 @@ a.btn-link:hover,a.btn-link:active,a.btn-link:focus{opacity:0.9;filter:alpha(opa
 .news-feed .media .media-body .media-footer{margin:0px;color:#999999;font-size:10.5px;font-weight:normal;}.news-feed .media .media-body .media-footer a{color:#555555;}
 .news-feed hr{border:none;border-top:1px solid #eeeeee;margin:20px 0px;}
 .category-forums-list{background-color:#ffffff;border:1px solid #d5d5d5;border-radius:2px;-webkit-box-shadow:0px 0px 0px 3px #eeeeee;-moz-box-shadow:0px 0px 0px 3px #eeeeee;box-shadow:0px 0px 0px 3px #eeeeee;margin-bottom:20px;}.category-forums-list table{margin:0px;}.category-forums-list table tr:first-child td{border-top:none;}
-.category-forums-list table td{padding:11.75px 9.9px;}
+.category-forums-list table td{padding:14.75px 9.9px;}
 .category-forums-list table .forum-icon{padding-right:2.95px;width:1%;}.category-forums-list table .forum-icon .forum-icon-wrap{background-color:#555555;border:1px solid #3b3b3b;border-radius:3px;padding:3px 4px;}.category-forums-list table .forum-icon .forum-icon-wrap.forum-icon-new{background-color:#cf402e;border:1px solid #a53325;}
 .category-forums-list table .forum-icon .forum-icon-wrap.forum-icon-redirect{background-color:#9466c6;border:1px solid #7a43b6;}
 .category-forums-list table .forum-main h3{float:left;margin:0px;padding:0px;font-size:17.5px;font-weight:normal;line-height:20px;}.category-forums-list table .forum-main h3 a:link,.category-forums-list table .forum-main h3 a:visited{color:#333333;}
-.category-forums-list table .forum-main .forum-details{float:right;margin-top:-1.0999999999999996px;color:#999999;font-size:11.9px;}.category-forums-list table .forum-main .forum-details strong,.category-forums-list table .forum-main .forum-details a{color:#555555;font-weight:normal;}
+.category-forums-list table .forum-main .forum-details{background-color:#f7f7f7;border-bottom:1px solid #f0f0f0;border-radius:3px;float:right;margin:-2px 0px;margin-top:-3.0999999999999996px;padding:2px 8px;width:230px;color:#999999;font-size:11.9px;}.category-forums-list table .forum-main .forum-details strong,.category-forums-list table .forum-main .forum-details a{color:#555555;font-weight:normal;}
 .category-forums-list table .forum-main .forum-details a:hover,.category-forums-list table .forum-main .forum-details a:active{color:#333333;}
 .category-forums-list table .forum-main .forum-details strong.stat-increment{color:#46a546;}
-.category-forums-list table .forum-main .forum-description{clear:both;margin:0px;margin-bottom:-2.0999999999999996px;padding:0px;color:#8c8c8c;font-size:11.9px;}
+.category-forums-list table .forum-main .forum-meta-tooltip .tooltip-inner{max-width:400px;text-align:left;}.category-forums-list table .forum-main .forum-meta-tooltip .tooltip-inner .forum-stats{color:#999999;font-size:10.5px;}.category-forums-list table .forum-main .forum-meta-tooltip .tooltip-inner .forum-stats strong{color:#ffffff;}
+.category-forums-list table .forum-main .forum-meta-tooltip .tooltip-inner .forum-stats span{margin-right:14px;}
+.category-forums-list table .forum-main .forum-meta-tooltip .tooltip-inner .forum-description{clear:both;margin:0px;margin-bottom:7px;padding:0px;color:#eeeeee;font-size:14px;}
 .category-forums-list.category-forums-important{border:1px solid #902d20;-webkit-box-shadow:0px 0px 0px 3px #cf402e;-moz-box-shadow:0px 0px 0px 3px #cf402e;box-shadow:0px 0px 0px 3px #cf402e;}
 .category-forums-list.category-forums-inverse{border:1px solid #333333;-webkit-box-shadow:0px 0px 0px 3px #555555;-moz-box-shadow:0px 0px 0px 3px #555555;box-shadow:0px 0px 0px 3px #555555;}
 .category-forums-list.category-forums-info{border:1px solid #27576b;-webkit-box-shadow:0px 0px 0px 3px #3c85a3;-moz-box-shadow:0px 0px 0px 3px #3c85a3;box-shadow:0px 0px 0px 3px #3c85a3;}
@@ -1037,14 +1051,16 @@ a.btn-link:hover,a.btn-link:active,a.btn-link:focus{opacity:0.9;filter:alpha(opa
 .user-profile .content-list .media .media-body{margin-left:66px;}.user-profile .content-list .media .media-body .post-preview:link,.user-profile .content-list .media .media-body .post-preview:active,.user-profile .content-list .media .media-body .post-preview:visited,.user-profile .content-list .media .media-body .post-preview:hover{display:block;margin-top:7px;color:#333333;font-size:16.8px;text-decoration:none;}
 .user-profile .content-list .media .media-body .media-footer{margin:0px;color:#999999;font-size:10.5px;font-weight:normal;}.user-profile .content-list .media .media-body .media-footer a{color:#555555;}
 .forum-subforums-list{background-color:#ffffff;border:1px solid #d5d5d5;border-radius:2px;-webkit-box-shadow:0px 0px 0px 3px #eeeeee;-moz-box-shadow:0px 0px 0px 3px #eeeeee;box-shadow:0px 0px 0px 3px #eeeeee;margin-bottom:20px;}.forum-subforums-list table{margin:0px;}.forum-subforums-list table caption{background-color:#fbfbfb;border:1px solid #d5d5d5;border-radius:2px 2px 0px 0px;margin:-1px;padding:3.966666666666667px 9.9px;color:#333333;font-size:11.9px;font-weight:bold;text-align:left;}.forum-subforums-list table caption small{margin-left:7px;color:#999999;font-size:11.9px;}
-.forum-subforums-list table td{padding:11.75px 9.9px;}
+.forum-subforums-list table td{padding:14.75px 9.9px;}
 .forum-subforums-list table .forum-icon{padding-right:2.95px;width:1%;}.forum-subforums-list table .forum-icon .forum-icon-wrap{background-color:#555555;border:1px solid #3b3b3b;border-radius:3px;padding:3px 4px;}.forum-subforums-list table .forum-icon .forum-icon-wrap.forum-icon-new{background-color:#cf402e;border:1px solid #a53325;}
 .forum-subforums-list table .forum-icon .forum-icon-wrap.forum-icon-redirect{background-color:#9466c6;border:1px solid #7a43b6;}
 .forum-subforums-list table .forum-main h3{float:left;margin:0px;padding:0px;font-size:17.5px;font-weight:normal;line-height:20px;}.forum-subforums-list table .forum-main h3 a:link,.forum-subforums-list table .forum-main h3 a:visited{color:#333333;}
-.forum-subforums-list table .forum-main .forum-details{float:right;margin-top:-1.0999999999999996px;color:#999999;font-size:11.9px;}.forum-subforums-list table .forum-main .forum-details strong,.forum-subforums-list table .forum-main .forum-details a{color:#555555;font-weight:normal;}
+.forum-subforums-list table .forum-main .forum-details{background-color:#f7f7f7;border-bottom:1px solid #f0f0f0;border-radius:3px;float:right;margin:-2px 0px;margin-top:-3.0999999999999996px;padding:2px 8px;width:230px;color:#999999;font-size:11.9px;}.forum-subforums-list table .forum-main .forum-details strong,.forum-subforums-list table .forum-main .forum-details a{color:#555555;font-weight:normal;}
 .forum-subforums-list table .forum-main .forum-details a:hover,.forum-subforums-list table .forum-main .forum-details a:active{color:#333333;}
 .forum-subforums-list table .forum-main .forum-details strong.stat-increment{color:#46a546;}
-.forum-subforums-list table .forum-main .forum-description{clear:both;margin:0px;margin-bottom:-2.0999999999999996px;padding:0px;color:#8c8c8c;font-size:11.9px;}
+.forum-subforums-list table .forum-main .forum-meta-tooltip .tooltip-inner{max-width:400px;text-align:left;}.forum-subforums-list table .forum-main .forum-meta-tooltip .tooltip-inner .forum-stats{color:#999999;font-size:10.5px;}.forum-subforums-list table .forum-main .forum-meta-tooltip .tooltip-inner .forum-stats strong{color:#ffffff;}
+.forum-subforums-list table .forum-main .forum-meta-tooltip .tooltip-inner .forum-stats span{margin-right:14px;}
+.forum-subforums-list table .forum-main .forum-meta-tooltip .tooltip-inner .forum-description{clear:both;margin:0px;margin-bottom:7px;padding:0px;color:#eeeeee;font-size:14px;}
 .forum-subforums-list.forum-subforums-important caption{background-color:#cf402e;border:1px solid #a53325;color:#ffffff;text-shadow:0px 1px 0px #672017;}.forum-subforums-list.forum-subforums-important caption small{color:#280c09;text-shadow:none;}
 .forum-subforums-list.forum-subforums-inverse caption{background-color:#333333;border:1px solid #1a1a1a;color:#eeeeee;text-shadow:0px 1px 0px #000000;}.forum-subforums-list.forum-subforums-inverse caption small{color:#b3b3b3;text-shadow:none;}
 .forum-subforums-list.forum-subforums-info caption{background-color:#3c85a3;border:1px solid #2e677e;color:#ffffff;text-shadow:0px 1px 0px #1a3946;}.forum-subforums-list.forum-subforums-info caption small{color:#1a3946;text-shadow:none;}
@@ -1089,8 +1105,9 @@ a.btn-link:hover,a.btn-link:active,a.btn-link:focus{opacity:0.9;filter:alpha(opa
 .thread-body .post-wrapper .post-body .post-content .post-footer .post-rating span.post-like{color:#46a546;}
 .thread-body .post-wrapper .post-body .post-content .post-footer .post-rating span.post-hate{color:#cf402e;}
 .thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form{float:left;margin:0px;padding:0px;}.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link{float:right;margin:0px;margin-left:3.5px;opacity:1;filter:alpha(opacity=100);padding:0px;color:#999999;font-weight:normal;}.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link:hover,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link:active,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link:focus{text-decoration:underline;}
-.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-like:hover,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-like:active,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-like:focus{color:#46a546;}
-.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-hate:hover,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-hate:active,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-hate:focus{color:#cf402e;}
+.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-like:hover,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-like:active,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-like:focus,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-like:disabled{color:#46a546;}
+.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-hate:hover,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-hate:active,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-hate:focus,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link.post-hate:disabled{color:#cf402e;}
+.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link:disabled:hover,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link:disabled:active,.thread-body .post-wrapper .post-body .post-content .post-footer .post-rating form .btn-link:disabled:focus{text-decoration:none;}
 .thread-body .post-wrapper .post-body .post-content .post-footer .post-actions{border-left:1px dotted #e7e7e7;float:right;padding:7px 14px;color:#999999;}.thread-body .post-wrapper .post-body .post-content .post-footer .post-actions a,.thread-body .post-wrapper .post-body .post-content .post-footer .post-actions span,.thread-body .post-wrapper .post-body .post-content .post-footer .post-actions form{float:left;overflow:auto;}
 .thread-body .post-wrapper .post-body .post-content .post-footer .post-actions form{margin:0px;padding:0px;}
 .thread-body .post-wrapper .post-body .post-content .post-footer .post-actions a{margin-left:14px;color:#999999;}.thread-body .post-wrapper .post-body .post-content .post-footer .post-actions a:hover,.thread-body .post-wrapper .post-body .post-content .post-footer .post-actions a a:active{color:#333333;}
@@ -1104,16 +1121,25 @@ a.btn-link:hover,a.btn-link:active,a.btn-link:focus{opacity:0.9;filter:alpha(opa
 .thread-body .post-checkpoints .post-checkpoint span{background-color:#fbfbfb;padding:0px 14px;color:#999999;}.thread-body .post-checkpoints .post-checkpoint span a{color:#333333;}
 .thread-body .post-checkpoints .post-checkpoint span i{opacity:0.43;filter:alpha(opacity=43);}
 .thread-moderation{background-color:#ffffff;border:1px solid #d5d5d5;border-radius:3px;-webkit-box-shadow:0px 0px 0px 3px #eeeeee;-moz-box-shadow:0px 0px 0px 3px #eeeeee;box-shadow:0px 0px 0px 3px #eeeeee;margin-bottom:20px;overflow:auto;padding:7px;}.thread-moderation form{margin:0px;}
-.thread-quick-reply{overflow:auto;margin-top:20px;}.thread-quick-reply .user-avatar{border-radius:3px;float:left;width:125px;height:125px;}
-.thread-quick-reply .editor{margin-left:153px;position:relative;}.thread-quick-reply .editor:after,.thread-quick-reply .editor:before{right:100%;border:solid transparent;content:"";height:0;width:0;position:absolute;pointer-events:none;}
-.thread-quick-reply .editor:after{border-color:transparent;border-right-color:#ffffff;border-width:14px;top:7px;margin-top:7px;}
-.thread-quick-reply .editor:before{border-color:transparent;border-right-color:#e6e6e6;border-width:15px;top:7px;margin-top:6px;}
-.post-votes-list .vote-user,.post-votes-list .vote-user:link,.post-votes-list .vote-user:visited{display:block;color:#555555;font-size:17.5px;font-weight:bold;}
+.thread-quick-reply{overflow:auto;margin-top:20px;}.thread-quick-reply .user-avatar{border-radius:3px;float:left;width:100px;height:100px;}
+.thread-quick-reply .editor{margin-left:121px;position:relative;}.thread-quick-reply .editor:after,.thread-quick-reply .editor:before{right:100%;border:solid transparent;content:"";height:0;width:0;position:absolute;pointer-events:none;}
+.thread-quick-reply .editor:after{border-color:transparent;border-right-color:#ffffff;border-width:10.5px;top:14px;margin-top:0px;}
+.thread-quick-reply .editor:before{border-color:transparent;border-right-color:#e6e6e6;border-width:11.5px;top:14px;margin-top:-1px;}
+.thread-participants h3{margin:0px;margin-top:-6px;padding:0px;color:#555555;font-size:17.5px;font-weight:bold;}
+.thread-participants ul{background-color:#ffffff;border:1px solid #e2e2e2;border-radius:3px;margin:0px;margin-bottom:20px;padding:0px;}.thread-participants ul li{border-bottom:1px dotted #e2e2e2;margin:0px;padding:6px 8px;font-weight:bold;}.thread-participants ul li img{background-color:#ffffff;border-radius:2px;width:24px;height:24px;}
+.thread-participants ul li a:link,.thread-participants ul li a:active,.thread-participants ul li a:visited,.thread-participants ul li a:hover{margin:0px 4px;color:#333333;font-weight:bold;}
+.thread-participants ul li:last-child{border-bottom:none;}
+.thread-participants ul li form{float:right;margin:0px;padding:0px;}.thread-participants ul li form button{padding-left:5px;padding-right:5px;}.thread-participants ul li form button i{position:relative;top:1px;}
+.thread-participants h4{margin:0px;padding:0px;color:#555555;font-size:16.8px;font-weight:bold;}
+.thread-participants .no-participants{margin-bottom:20px;}
+.thread-participants .invite-participant{background-color:#ffffff;border:1px solid #d5d5d5;border-radius:3px;margin-top:6px;padding:1px;}.thread-participants .invite-participant form{margin:0px;padding:0px;}.thread-participants .invite-participant form input,.thread-participants .invite-participant form button{border:none;background:none;box-shadow:none;}
+.thread-participants .invite-participant form input{width:70%;}
+.thread-participants .invite-participant form button{float:right;}
+.post-votes-list .vote-user,.post-votes-list .vote-user:link,.post-votes-list .vote-user:visited{color:#555555;font-size:17.5px;font-weight:bold;}
 .post-votes-list .vote-user .vote-icon{background-color:#999999;border-radius:3px;padding:2px 3px;position:relative;bottom:1.75px;font-size:14px;}.post-votes-list .vote-user .vote-icon i{background-image:url("../img/glyphicons-halflings-white.png");}
 .post-votes-list a.vote-user:hover,.post-votes-list a.vote-user:active{color:#333333;text-decoration:none;}
 .post-votes-list .post-likes .vote-icon{background-color:#46a546;}
 .post-votes-list .post-hates .vote-icon{background-color:#cf402e;}
-.post-votes-list .vote-date{margin:0px;padding:0px;color:#999999;font-size:10.5px;}
 .post-changelog table td{vertical-align:middle;}.post-changelog table td .change-added,.post-changelog table td .change-removed,.post-changelog table td .change-none{display:block;font-size:28px;font-weight:bold;text-align:right;}.post-changelog table td .change-added.change-small,.post-changelog table td .change-removed.change-small,.post-changelog table td .change-none.change-small{font-size:14px;}
 .post-changelog table td .change-neutral{color:#555555;}
 .post-changelog table td .change-added{color:#46a546;}
@@ -1132,5 +1158,5 @@ a.btn-link:hover,a.btn-link:active,a.btn-link:focus{opacity:0.9;filter:alpha(opa
 .post-label-team{background-color:#cf402e;}
 .index-rank-mvp ul li .label{background-color:#049cdb;color:#ffffff;text-shadow:0px 1px 0px #011f2c;}
 .post-label-mvp{background-color:#049cdb;}
-.index-rank-active ul li .label{background-color:#8d6a00;color:#ffffff;text-shadow:0px 1px 0px #5a4400;}
-.post-label-active{background-color:#ffc40d;}
+.index-rank-top ul li .label{background-color:#7c4a03;color:#ffffff;text-shadow:0px 1px 0px #4a2c02;}
+.post-label-top{background-color:#f89406;}

+ 36 - 10
static/cranefly/css/cranefly/category.less

@@ -18,7 +18,7 @@
     }
     
     td {
-      padding: ((@fontSizeLarge / 2) + 3px) (@fontSizeSmall - 2px);
+      padding: ((@fontSizeLarge / 2) + 6px) (@fontSizeSmall - 2px);
     }
 
     .forum-icon {
@@ -59,8 +59,14 @@
       }
 
       .forum-details {
+        background-color: darken(@categoryBackground, 3%);
+        border-bottom: 1px solid darken(@categoryBackground, 6%);
+        border-radius: @baseBorderRadius;
         float: right;
-        margin-top: ((@baseFontSize - @fontSizeSmall) * -1) + 1px;
+        margin: -2px 0px;
+        margin-top: ((@baseFontSize - @fontSizeSmall) * -1) - 1px;
+        padding: 2px 8px;
+        width: 230px;
 
         color: @grayLight;
         font-size: @fontSizeSmall;
@@ -79,14 +85,34 @@
         }
       }
 
-      .forum-description {
-        clear: both;
-        margin: 0px;
-        margin-bottom: (@baseFontSize - @fontSizeSmall) * -1;
-        padding: 0px;
-
-        color: lighten(@textColor, 35%);
-        font-size: @fontSizeSmall;
+      .forum-meta-tooltip {
+        .tooltip-inner {
+          max-width: 400px;
+          text-align: left;
+
+          .forum-stats {
+            color: @grayLight;
+            font-size: @fontSizeMini;
+
+            strong {
+              color: @white;
+            }
+
+            span {
+              margin-right: @baseFontSize;
+            }
+          }
+
+          .forum-description {
+            clear: both;
+            margin: 0px;
+            margin-bottom: @baseFontSize / 2;
+            padding: 0px;
+
+            color: @grayLighter;
+            font-size: @baseFontSize;
+          }
+        }
       }
     }
   }

+ 35 - 9
static/cranefly/css/cranefly/forum.less

@@ -33,7 +33,7 @@
     }
     
     td {
-      padding: ((@fontSizeLarge / 2) + 3px) (@fontSizeSmall - 2px);
+      padding: ((@fontSizeLarge / 2) + 6px) (@fontSizeSmall - 2px);
     }
 
     .forum-icon {
@@ -74,8 +74,14 @@
       }
 
       .forum-details {
+        background-color: darken(@categoryBackground, 3%);
+        border-bottom: 1px solid darken(@categoryBackground, 6%);
+        border-radius: @baseBorderRadius;
         float: right;
-        margin-top: ((@baseFontSize - @fontSizeSmall) * -1) + 1px;
+        margin: -2px 0px;
+        margin-top: ((@baseFontSize - @fontSizeSmall) * -1) - 1px;
+        padding: 2px 8px;
+        width: 230px;
 
         color: @grayLight;
         font-size: @fontSizeSmall;
@@ -94,14 +100,34 @@
         }
       }
 
-      .forum-description {
-        clear: both;
-        margin: 0px;
-        margin-bottom: (@baseFontSize - @fontSizeSmall) * -1;
-        padding: 0px;
+      .forum-meta-tooltip {
+        .tooltip-inner {
+          max-width: 400px;
+          text-align: left;
 
-        color: lighten(@textColor, 35%);
-        font-size: @fontSizeSmall;
+          .forum-stats {
+            color: @grayLight;
+            font-size: @fontSizeMini;
+
+            strong {
+              color: @white;
+            }
+
+            span {
+              margin-right: @baseFontSize;
+            }
+          }
+
+          .forum-description {
+            clear: both;
+            margin: 0px;
+            margin-bottom: @baseFontSize / 2;
+            padding: 0px;
+
+            color: @grayLighter;
+            font-size: @baseFontSize;
+          }
+        }
       }
     }
   }

+ 48 - 1
static/cranefly/css/cranefly/header.less

@@ -47,7 +47,7 @@
   }
 
   .header-stats {
-    overflow: auto;
+    overflow: visible;
     margin-bottom: 0px;
 
     color: @grayLight;
@@ -67,6 +67,26 @@
       &>i {
         .opacity(50);
       }
+
+      &.stats-form {
+        float: right;
+
+        form {
+          margin: 0px;
+          margin-bottom: -12px;
+          padding: 0px;
+
+          button {
+            position: relative;
+            bottom: 12px;
+
+            &>i {
+              position: relative;
+              top: 0px;
+            }
+          }
+        }
+      }
     }
   }
 
@@ -118,6 +138,33 @@
           padding-left: 7px;
           padding-right: 7px;
         }
+
+        i {
+          position: relative;
+          top: 0px;
+        }
+      }
+
+      & {
+        a.btn {
+          border: 1px solid @btnBorder;
+          *border: 0; // Remove the border to prevent IE7's black border on input:focus
+          border-radius: @baseBorderRadius;
+          margin: 0px;
+          padding: 4px 12px;
+
+          color: @textColor;
+
+          i {
+            position: relative;
+            top: 0px;
+          }
+
+          &:visited, &:hover {
+            background-color: @white;
+            border-color: lighten(@grayLight, 5%);
+          }
+        }
       }
     }
   }

+ 36 - 10
static/cranefly/css/cranefly/index.less

@@ -40,7 +40,7 @@
   	}
     
     td {
-      padding: ((@fontSizeLarge / 2) + 3px) (@fontSizeSmall - 2px);
+      padding: ((@fontSizeLarge / 2) + 6px) (@fontSizeSmall - 2px);
     }
 
     .forum-icon {
@@ -81,8 +81,14 @@
       }
 
       .forum-details {
+        background-color: darken(@categoryBackground, 3%);
+        border-bottom: 1px solid darken(@categoryBackground, 6%);
+        border-radius: @baseBorderRadius;
         float: right;
-        margin-top: ((@baseFontSize - @fontSizeSmall) * -1) + 1px;
+        margin: -2px 0px;
+        margin-top: ((@baseFontSize - @fontSizeSmall) * -1) - 1px;
+        padding: 2px 8px;
+        width: 230px;
 
         color: @grayLight;
         font-size: @fontSizeSmall;
@@ -101,14 +107,34 @@
         }
       }
 
-      .forum-description {
-        clear: both;
-        margin: 0px;
-        margin-bottom: (@baseFontSize - @fontSizeSmall) * -1;
-        padding: 0px;
-
-        color: lighten(@textColor, 35%);
-        font-size: @fontSizeSmall;
+      .forum-meta-tooltip {
+        .tooltip-inner {
+          max-width: 400px;
+          text-align: left;
+
+          .forum-stats {
+            color: @grayLight;
+            font-size: @fontSizeMini;
+
+            strong {
+              color: @white;
+            }
+
+            span {
+              margin-right: @baseFontSize;
+            }
+          }
+
+          .forum-description {
+            clear: both;
+            margin: 0px;
+            margin-bottom: @baseFontSize / 2;
+            padding: 0px;
+
+            color: @grayLighter;
+            font-size: @baseFontSize;
+          }
+        }
       }
     }
   }

+ 0 - 10
static/cranefly/css/cranefly/karmas.less

@@ -4,8 +4,6 @@
 .post-votes-list {
   .vote-user {
     &, &:link, &:visited {
-      display: block;
-
       color: @gray;
       font-size: @fontSizeLarge;
       font-weight: bold;
@@ -44,12 +42,4 @@
       background-color: @red;
     }
   }
-
-  .vote-date {
-    margin: 0px;
-    padding: 0px;
-    
-    color: @grayLight;
-    font-size: @fontSizeMini;
-  }
 }

+ 4 - 3
static/cranefly/css/cranefly/markdown.less

@@ -115,12 +115,13 @@
         .md-img-bg {
           background-color: @white;
           border-radius: @baseBorderRadius;
+          padding: (@baseLineHeight / 2);
+          
           text-align: center;
 
           img {
             border-radius: @baseBorderRadius;
             box-shadow: none;
-            margin: (@baseLineHeight / 2);
           }
 
           .md-img-error {
@@ -158,8 +159,8 @@
 
   // Blocks margins
   pre, blockquote, iframe {
-    margin-top: @baseLineHeight / 2;
-    margin-bottom: @baseLineHeight / 2;
+    margin-top: @baseLineHeight;
+    margin-bottom: @baseLineHeight;
 
     &>:first-child {
       margin-top: 0px;

+ 84 - 2
static/cranefly/css/cranefly/navbar.less

@@ -1,7 +1,6 @@
 // Navbar
 // -------------------------
-
-.navbar {
+.navbar-header {
   .navbar-inner {
     background: none;
     background-color: @navbarBackground;
@@ -174,4 +173,87 @@
       }
     }
   }
+}
+
+// Inversed navbar, used for moderation tools
+.navbar-modbar {
+  .navbar-inner {
+    background: none;
+    background-color: @grayDarker;
+    background-image: -webkit-gradient(linear, 0 0, 100% 100%,
+                color-stop(.25, rgba(0, 0, 0, 0.1)), color-stop(.25, transparent),
+                color-stop(.5, transparent), color-stop(.5, rgba(0, 0, 0, 0.1)),
+                color-stop(.75, rgba(0, 0, 0, 0.1)), color-stop(.75, transparent),
+                to(transparent));
+    background-image: -webkit-linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%,
+              transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%,
+              transparent 75%, transparent);
+    background-image: -moz-linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%,
+              transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%,
+              transparent 75%, transparent);
+    background-image: -ms-linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%,
+              transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%,
+              transparent 75%, transparent);
+    background-image: -o-linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%,
+              transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%,
+              transparent 75%, transparent);
+    background-image: linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%,
+              transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%,
+              transparent 75%, transparent);
+    -webkit-background-size: 15px 15px;
+    -moz-background-size: 15px 15px;
+    background-size: 15px 15px;
+    border-bottom: 2px dashed @red;
+    min-height: 1px;
+
+    .nav {
+      a:link, a:visited {
+        padding: @fontSizeMini (@fontSizeMini * 1.4);
+
+        color: darken(@white, 30%);
+        text-shadow: 0px 1px 0px @black;
+      }
+
+      a:hover, a:active {
+        color: @white;
+      }
+
+      li.active {
+        a:link, a:visited {
+          color: @white;
+        }
+      }
+    }
+
+    .navbar-search-form {
+      background-color: darken(@grayDark, 15%);
+      border-radius: @baseBorderRadius;
+      margin-top: ((@navbarHeight - @baseLineHeight) / 2) - 9px;
+      padding: 1px 0px;
+
+      input {
+        background-color: darken(@grayDark, 15%);
+        border: none;
+        .box-shadow(none);
+        margin: 0px;
+
+        color: @grayLighter;
+        text-shadow: 0px 1px 0px @black;
+      }
+
+      button {
+        margin: 0px;
+        .opacity(60);
+
+        i {
+          background-image: url("@{iconWhiteSpritePath}");
+          .opacity(100);
+        }
+
+        &:hover, &:active {
+          .opacity(100);
+        }
+      }
+    }
+  }
 }

+ 121 - 11
static/cranefly/css/cranefly/thread.less

@@ -233,16 +233,22 @@
                 }
 
                 &.post-like {
-                  &:hover, &:active, &:focus {
+                  &:hover, &:active, &:focus, &:disabled {
                     color: @green;
                   }
                 }
 
                 &.post-hate {
-                  &:hover, &:active, &:focus {
+                  &:hover, &:active, &:focus, &:disabled {
                     color: @red;
                   }
                 }
+
+                &:disabled {
+                  &:hover, &:active, &:focus {
+                    text-decoration: none;
+                  }
+                }
               }
             }
           }
@@ -421,12 +427,12 @@
   .user-avatar {
     border-radius: @baseBorderRadius;
     float: left;
-    width: 125px;
-    height: 125px;
+    width: 100px;
+    height: 100px;
   }
 
   .editor {
-    margin-left: 125px + (@baseFontSize * 2);
+    margin-left: 100px + (@baseFontSize * 1.5);
     position: relative;
 
     &:after, &:before {
@@ -441,17 +447,121 @@
     &:after {
       border-color: transparent;
       border-right-color: @editorBackground;
-      border-width: @baseFontSize;
-      top: @baseFontSize / 2;
-      margin-top: ((@baseFontSize / 2) * -1) + @baseFontSize;
+      border-width: @fontSizeMini;
+      top: @baseFontSize;
+      margin-top: (@baseFontSize * -1) + @baseFontSize;
     }
 
     &:before {
        border-color: transparent;
        border-right-color: darken(@editorBackground, 10%);
-       border-width: @baseFontSize + 1;
-       top: @baseFontSize / 2;
-       margin-top: ((@baseFontSize / 2) * -1) + @baseFontSize - 1px;
+       border-width: @fontSizeMini + 1;
+       top: @baseFontSize;
+       margin-top: (@baseFontSize * -1) + @baseFontSize - 1px;
     }
   }
 }
+
+// Thread participants list
+.thread-participants {
+  h3 {
+    margin: 0px;
+    margin-top: (@baseLineHeight - @baseFontSize) * -1;
+    padding: 0px;
+
+    color: @gray;
+    font-size: @fontSizeLarge;
+    font-weight: bold;
+  }
+
+  ul {
+    background-color: @white;
+    border: 1px solid darken(@bodyBackground, 10%);
+    border-radius: @baseBorderRadius;
+    margin: 0px;
+    margin-bottom: @baseLineHeight;
+    padding: 0px;
+
+    li {
+      border-bottom: 1px dotted darken(@bodyBackground, 10%);
+      margin: 0px;
+      padding: 6px 8px;
+
+      font-weight: bold;
+
+      img {
+        background-color: @white;
+        border-radius: @borderRadiusSmall;
+        width: 24px;
+        height: 24px;
+      }
+
+      a:link, a:active, a:visited, a:hover {
+        margin: 0px 4px;
+
+        color: @textColor;
+        font-weight: bold;
+      }
+
+      &:last-child {
+        border-bottom: none;
+      }
+
+      form {
+        float: right;
+        margin: 0px;
+        padding: 0px;
+
+        button {
+          padding-left: 5px;
+          padding-right: 5px;
+
+          i {
+            position: relative;
+            top: 1px;
+          }
+        }
+      }
+    }
+  }
+
+  h4 {
+    margin: 0px;
+    padding: 0px;
+
+    color: @gray;
+    font-size: @baseFontSize * 1.2;
+    font-weight: bold;
+  }
+
+  .no-participants {
+    margin-bottom: @baseLineHeight;
+  }
+
+  .invite-participant {
+    background-color: @white;
+    border: 1px solid darken(@bodyBackground, 15%);
+    border-radius: @baseBorderRadius;
+    margin-top: @baseLineHeight - @baseFontSize;
+    padding: 1px;
+
+    form {
+      margin: 0px;
+      padding: 0px;
+
+      input, button {
+        border: none;
+        background: none;
+        box-shadow: none;
+      }
+
+      input {
+        width: 70%;
+      }
+
+      button {
+        float: right;
+      }
+    }
+  }
+}

+ 6 - 6
static/cranefly/css/ranks.less

@@ -33,18 +33,18 @@
   background-color: @blue;
 }
 
-// .rank-active
-.index-rank-active ul {
+// .rank-top
+.index-rank-top ul {
   li {
     .label {
-      background-color: darken(@yellow, 25%);
+      background-color: darken(@orange, 25%);
 
       color: @white;
-      text-shadow: 0px 1px 0px darken(@yellow, 35%);
+      text-shadow: 0px 1px 0px darken(@orange, 35%);
     }
   }
 }
 
-.post-label-active {
-  background-color: @yellow;
+.post-label-top {
+  background-color: @orange;
 }

+ 50 - 1
static/cranefly/js/cranefly.js

@@ -143,4 +143,53 @@ function link2player(link) {
 
 	// No link
 	return false;
-}
+}
+
+// Ajax errors handler
+$(document).ajaxError(function(event, jqXHR, settings) {
+	var responseJSON = jQuery.parseJSON(jqXHR.responseText);
+	if (responseJSON.message) {
+		alert(responseJSON.message);
+	}
+});
+
+// Ajax: Post votes
+$(function() {
+	$('.post-rating-actions').each(function() {
+		var action_parent = this;
+		var csrf_token = $(this).find('input[name="_csrf_token"]').val();
+		$(this).find('form').submit(function() {
+			var form = this;
+			$.post(this.action, {'_csrf_token': csrf_token}, "json").done(function(data, textStatus, jqXHR) {
+				// Reset stuff and set classess
+				$(action_parent).find('.post-score').removeClass('post-score-good post-score-bad');
+				if (data.score_total > 0) {
+					$(action_parent).find('.post-score-total').addClass('post-score-good');
+				} else if (data.score_total < 0) {
+					$(action_parent).find('.post-score-total').addClass('post-score-bad');
+				} 
+				if (data.score_upvotes > 0) {
+					$(action_parent).find('.post-score-upvotes').addClass('post-score-good');
+				}
+				if (data.score_downvotes > 0) {
+					$(action_parent).find('.post-score-downvotes').addClass('post-score-bad');
+				}
+
+				// Set votes
+				$(action_parent).find('.post-score-total').text(data.score_total);
+				$(action_parent).find('.post-score-upvotes').text(data.score_upvotes);
+				$(action_parent).find('.post-score-downvotes').text(data.score_downvotes);
+
+				// Disable and enable forms
+				if (data.user_vote == 1) {
+					$(action_parent).find('.form-upvote button').attr("disabled", "disabled");
+					$(action_parent).find('.form-downvote button').removeAttr("disabled");
+				} else {
+					$(action_parent).find('.form-upvote button').removeAttr("disabled");
+					$(action_parent).find('.form-downvote button').attr("disabled", "disabled");
+				}
+			}).error();
+			return false;
+		});
+	});
+});

+ 0 - 0
templates/_email/base_html.html → templates/_email/base.html


+ 0 - 0
templates/_email/base_plain.html → templates/_email/base.txt


+ 9 - 0
templates/_email/private_thread_invite.html

@@ -0,0 +1,9 @@
+{% extends "_email/base.html" %}
+
+{% block title %}{% trans %}You've been invited to private thread{% endtrans %}{% endblock %}
+
+{% block content %}
+<p {{ style_p|safe }}>{% trans username=user.username, author=author.username, thread=thread.name %}{{ username }}, you are receiving this message because {{ author }} has invited you to participate in private thread "{{ thread }}".{% endtrans %}</p>
+<p {{ style_p|safe }}>{% trans %}You can see this thread by clicking link below:{% endtrans %}</p>
+<a href="{{ board_address }}{% url 'private_thread' thread=thread.pk, slug=thread.slug %}" {{ style_link|safe }}>{{ board_address }}{% url 'private_thread' thread=thread.pk, slug=thread.slug %}</a>
+{% endblock %}

+ 10 - 0
templates/_email/private_thread_invite.txt

@@ -0,0 +1,10 @@
+{% extends "_email/base.txt" %}
+
+{% block title %}{% trans %}You've been invited to private thread{% endtrans %}{% endblock %}
+
+{% block content %}
+{% trans username=user.username, author=author.username, thread=thread.name %}{{ username }}, you are receiving this message because {{ author }} has invited you to participate in private thread "{{ thread }}".{% endtrans %}
+
+{% trans %}You can see this thread by clicking link below:{% endtrans %}
+{{ board_address }}{% url 'private_thread' thread=thread.pk, slug=thread.slug %}
+{% endblock %}

+ 9 - 0
templates/_email/private_thread_reply_notification.html

@@ -0,0 +1,9 @@
+{% extends "_email/base.html" %}
+
+{% block title %}{% trans %}New reply notification{% endtrans %}{% endblock %}
+
+{% block content %}
+<p {{ style_p|safe }}>{% trans username=user.username, author=author.username, thread=thread.name %}{{ username }}, you are receiving this message because {{ author }} has replied to private thread "{{ thread }}" that you are watching.{% endtrans %}</p>
+<p {{ style_p|safe }}>{% trans %}To go to this reply follow the link below:{% endtrans %}</p>
+<a href="{{ board_address }}{% url 'private_thread_find' thread=thread.pk, slug=thread.slug, post=post.pk %}" {{ style_link|safe }}>{{ board_address }}{% url 'private_thread_find' thread=thread.pk, slug=thread.slug, post=post.pk %}</a>
+{% endblock %}

+ 10 - 0
templates/_email/private_thread_reply_notification.txt

@@ -0,0 +1,10 @@
+{% extends "_email/base.txt" %}
+
+{% block title %}{% trans %}New reply notification{% endtrans %}{% endblock %}
+
+{% block content %}
+{% trans username=user.username, author=author.username, thread=thread.name %}{{ username }}, you are receiving this message because {{ author }} has replied to private thread "{{ thread }}" that you are watching.{% endtrans %}
+
+{% trans %}To go to this reply follow the link below:{% endtrans %}
+{{ board_address }}{% url 'private_thread_find' thread=thread.pk, slug=thread.slug, post=post.pk %}
+{% endblock %}

+ 1 - 1
templates/_email/post_notification_html.html → templates/_email/thread_reply_notification.html

@@ -1,4 +1,4 @@
-{% extends "_email/base_html.html" %}
+{% extends "_email/base.html" %}
 
 {% block title %}{% trans %}New reply notification{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/post_notification_plain.html → templates/_email/thread_reply_notification.txt

@@ -1,4 +1,4 @@
-{% extends "_email/base_plain.html" %}
+{% extends "_email/base.txt" %}
 
 {% block title %}{% trans %}New reply notification{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/activation/admin_html.html → templates/_email/users/activation/admin.html

@@ -1,4 +1,4 @@
-{% extends "_email/base_html.html" %}
+{% extends "_email/base.html" %}
 
 {% block content %}
 {{ super() }}

+ 1 - 1
templates/_email/users/activation/admin_plain.html → templates/_email/users/activation/admin.txt

@@ -1,4 +1,4 @@
-{% extends "_email/users/activation/none_plain.html" %}
+{% extends "_email/users/activation/none.txt" %}
 
 {% block content %}
 {{ super() }}

+ 1 - 1
templates/_email/users/activation/admin_done_html.html → templates/_email/users/activation/admin_done.html

@@ -1,4 +1,4 @@
-{% extends "_email/users/activation/none_html.html" %}
+{% extends "_email/users/activation/none.html" %}
 
 {% block content %}
 <p {{ style_p|safe }}>{% trans username=user.username %}{{ username }}, you are receiving this message because board administrator has activated your account.{% endtrans %}</p>

+ 1 - 1
templates/_email/users/activation/admin_done_plain.html → templates/_email/users/activation/admin_done.txt

@@ -1,4 +1,4 @@
-{% extends "_email/users/activation/none_plain.html" %}
+{% extends "_email/users/activation/none.txt" %}
 
 {% block content %}
 {% trans username=user.username %}{{ username }}, you are receiving this message because board administrator has activated your account.{% endtrans %}

+ 1 - 1
templates/_email/users/activation/invalidated_html.html → templates/_email/users/activation/invalidated.html

@@ -1,4 +1,4 @@
-{% extends "_email/base_html.html" %}
+{% extends "_email/base.html" %}
 
 {% block title %}{% trans board_name=settings.board_name %}Account Activation on {{ board_name }}{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/activation/invalidated_plain.html → templates/_email/users/activation/invalidated.txt

@@ -1,4 +1,4 @@
-{% extends "_email/base_plain.html" %}
+{% extends "_email/base.txt" %}
 
 {% block title %}{% trans board_name=settings.board_name %}Account Activation on {{ board_name }}{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/activation/none_html.html → templates/_email/users/activation/none.html

@@ -1,4 +1,4 @@
-{% extends "_email/base_html.html" %}
+{% extends "_email/base.html" %}
 
 {% block title %}{% trans board_name=settings.board_name %}Welcome aboard {{ board_name }}!{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/activation/none_plain.html → templates/_email/users/activation/none.txt

@@ -1,4 +1,4 @@
-{% extends "_email/base_plain.html" %}
+{% extends "_email/base.txt" %}
 
 {% block title %}{% trans board_name=settings.board_name %}Welcome aboard {{ board_name }}!{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/activation/resend_html.html → templates/_email/users/activation/resend.html

@@ -1,4 +1,4 @@
-{% extends "_email/base_html.html" %}
+{% extends "_email/base.html" %}
 
 {% block title %}{% trans board_name=settings.board_name %}Account Activation on {{ board_name }}{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/activation/resend_plain.html → templates/_email/users/activation/resend.txt

@@ -1,4 +1,4 @@
-{% extends "_email/base_plain.html" %}
+{% extends "_email/base.txt" %}
 
 {% block title %}{% trans board_name=settings.board_name %}Account Activation on {{ board_name }}{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/activation/user_html.html → templates/_email/users/activation/user.html

@@ -1,4 +1,4 @@
-{% extends "_email/users/activation/none_html.html" %}
+{% extends "_email/users/activation/none.html" %}
 
 {% block content %}
 {{ super() }}

+ 1 - 1
templates/_email/users/activation/user_plain.html → templates/_email/users/activation/user.txt

@@ -1,4 +1,4 @@
-{% extends "_email/users/activation/none_plain.html" %}
+{% extends "_email/users/activation/none.txt" %}
 
 {% block content %}
 {{ super() }}

+ 1 - 1
templates/_email/users/new_credentials_html.html → templates/_email/users/new_credentials.html

@@ -1,4 +1,4 @@
-{% extends "_email/base_html.html" %}
+{% extends "_email/base.html" %}
 
 {% block title %}{% trans board_name=settings.board_name %}Activate new Sign-In Credentials on {{ board_name }}{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/new_credentials_plain.html → templates/_email/users/new_credentials.txt

@@ -1,4 +1,4 @@
-{% extends "_email/base_plain.html" %}
+{% extends "_email/base.txt" %}
 
 {% block title %}{% trans board_name=settings.board_name %}Activate new Sign-In Credentials on {{ board_name }}{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/newsletter_html.html → templates/_email/users/newsletter.html

@@ -1,4 +1,4 @@
-{% extends "_email/base_html.html" %}
+{% extends "_email/base.html" %}
 
 {% block title %}{{ subject }}{% endblock %}
 

+ 1 - 1
templates/_email/users/newsletter_plain.html → templates/_email/users/newsletter.txt

@@ -1,4 +1,4 @@
-{% extends "_email/base_plain.html" %}
+{% extends "_email/base.txt" %}
 
 {% block title %}{{ subject }}{% endblock %}
 

+ 1 - 1
templates/_email/users/password/confirm_html.html → templates/_email/users/password/confirm.html

@@ -1,4 +1,4 @@
-{% extends "_email/base_html.html" %}
+{% extends "_email/base.html" %}
 
 {% block title %}{% trans board_name=settings.board_name %}Confirm New Password Request on {{ board_name }}{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/password/confirm_plain.html → templates/_email/users/password/confirm.txt

@@ -1,4 +1,4 @@
-{% extends "_email/base_plain.html" %}
+{% extends "_email/base.txt" %}
 
 {% block title %}{% trans board_name=settings.board_name %}Confirm New Password Request on {{ board_name }}{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/password/new_html.html → templates/_email/users/password/new.html

@@ -1,4 +1,4 @@
-{% extends "_email/base_html.html" %}
+{% extends "_email/base.html" %}
 
 {% block title %}{% trans board_name=settings.board_name %}Your New Password on {{ board_name }}{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/password/new_plain.html → templates/_email/users/password/new.txt

@@ -1,4 +1,4 @@
-{% extends "_email/base_plain.html" %}
+{% extends "_email/base.txt" %}
 
 {% block title %}{% trans board_name=settings.board_name %}Your New Password on {{ board_name }}{% endtrans %}{% endblock %}
 

+ 1 - 1
templates/_email/users/password/new_admin_html.html → templates/_email/users/password/new_admin.html

@@ -1,4 +1,4 @@
-{% extends "_email/users/password/new_html.html" %}
+{% extends "_email/users/password/new.html" %}
 
 {% block content %}
 <p {{ style_p|safe }}>{% trans username=user.username %}{{ username }}, you are receiving this message because board administrator has reset your account's password with new one.{% endtrans %}</p>

+ 1 - 1
templates/_email/users/password/new_admin_plain.html → templates/_email/users/password/new_admin.txt

@@ -1,4 +1,4 @@
-{% extends "_email/users/password/new_plain.html" %}
+{% extends "_email/users/password/new.txt" %}
 
 {% block content %}
 {% trans username=user.username %}{{ username }}, you are receiving this message because board administrator has reset your account's password with new one.{% endtrans %}

+ 3 - 8
templates/admin/home.html → templates/admin/index.html

@@ -25,17 +25,12 @@
 <div class="row">
   <div class="span8">
   	
-  	<h2>Administrators Online</h2>
-    <table class="table table-striped table-users list-tiny">
-      <thead>
-        <tr>
-          <th{% if admins|length > 1 %} colspan="2"{% endif %}>{% trans count=admins|length, total=admins|length|intcomma -%}
+  	<h2>{% trans count=admins|length, total=admins|length|intcomma -%}
 One Administrator Online
 {%- pluralize -%}
 {{ total }} Administrators Online
-{%- endtrans %}</th>
-        </tr>
-      </thead>
+{%- endtrans %}</h2>
+    <table class="table table-striped table-users list-tiny">
       <tbody>
         {% for session in admins %}    	
         <tr>

+ 0 - 2
templates/admin/layout_compact.html

@@ -1,6 +1,4 @@
 {% extends "admin/base.html" %}
-{% load i18n %}
-{% load url from future %}
 
 {% block body_class %} class="layout-compact"{% endblock %}
 

+ 0 - 2
templates/admin/signin.html

@@ -1,6 +1,4 @@
 {% extends "admin/layout_compact.html" %}
-{% load i18n %}
-{% load url from future %}
 {% import "_forms.html" as form_theme with context %}
 {% from "admin/macros.html" import page_title, draw_message_icon %}
 

+ 1 - 1
templates/cranefly/base.html

@@ -8,7 +8,7 @@
     <link href="{{ STATIC_URL }}cranefly/css/cranefly.css" rel="stylesheet">{% block stylesheets %}{% endblock %}
     <link rel="shortcut icon" href="{{ STATIC_URL }}favicon.ico" />
   </head>
-  <body{% block body_class %}{% endblock %}>
+  <body itemscope itemtype="http://schema.org/WebPage"{% block body_class %}{% endblock %}>
   	{% block body %}{% endblock %}
 
   	<script src="{{ STATIC_URL }}cranefly/js/jquery-1.7.2.min.js"></script>

+ 41 - 42
templates/cranefly/category.html

@@ -14,7 +14,7 @@
 <div class="page-header header-primary">
   <div class="container">
     {{ messages_list(messages) }}
-    <ul class="breadcrumb">
+    <ul class="breadcrumb" {{ macros.itemprop_bread() }}>
       {{ self.breadcrumb() }}</li>
     </ul>
     <h1>{{ category.name }}</h1>
@@ -34,18 +34,32 @@
         {% for forum in category.subforums %}
         <tr>
           <td class="forum-icon"><span class="forum-icon-wrap{% if forum.type == 'redirect' %} forum-icon-redirect{% elif not forum.is_read %} forum-icon-new{% endif %}"><i class="icon-{% if forum.type == 'redirect' %}circle-arrow-right{% else %}comment{% endif %} icon-white"></i></span></td>
-          <td class="forum-main">
-            <h3{% if not forum.is_read %} class="forum-title-new"{% endif %}><a href="{{ forum.type|url(slug=forum.slug, forum=forum.id) }}">{{ forum.name }}</a></h3>
-            {% if forum.show_details %}
+          <td id="forum-{{ forum.id }}" class="forum-main">
+            <h3 class="forum-title{% if not forum.is_read %} forum-title-new{% endif %}"><a href="{{ forum.type|url(slug=forum.slug, forum=forum.id) }}">{{ forum.name }}</a></h3>
+            {% if forum.show_details and forum.type != 'redirect' %}
             <div class="forum-details">
-              {% if forum.type == 'redirect' %}
-              {{ redirect_stats(forum) }}
-              {% else %}
-              {{ forum_stats(forum) }}
-              {% endif %}
+              {% if acl.forums.can_browse(forum) and (acl.threads.can_read_threads(forum) == 2 or (acl.threads.can_read_threads(forum) == 1 and forum.last_poster_id == user.pk)) %}
+              {% if forum.last_thread_id -%}
+              {% trans %}Latest{% endtrans %}: <a href="{% url 'thread_new' thread=forum.last_thread_id, slug=forum.last_thread_slug %}" class="tooltip-top" title="{{ forum.last_thread_name }}">{{ forum.last_thread_name|short_string(28) }}</a>
+              {%- else -%}
+              <em>{% trans %}This forum is empty{% endtrans %}</em>
+              {%- endif %}
+              {%- else -%}
+              <em>{% trans %}This forum is protected{% endtrans %}</em>
+              {%- endif %}
             </div>
             {% endif %}
-            {% if forum.description %}<p class="forum-description">{{ forum.description }}</p>{% endif %}
+            <div class="hide forum-meta">
+              {% if forum.description %}<p class="forum-description">{{ forum.description }}</p>{% endif %}
+              <div class="forum-stats">
+                {% if forum.type != 'redirect' %}
+                <span>{% trans %}Posts{% endtrans %}: <strong>{{ forum.posts|intcomma }}</strong></span>
+                {% trans %}Threads{% endtrans %}: <strong>{{ forum.threads|intcomma }}</strong>
+                {% else %}
+                {% trans %}Clicks{% endtrans %}: <strong>{{ forum.redirects|intcomma }}</strong>
+                {% endif %}
+              </div>
+            </div>
           </td>
         </tr>
         {% endfor %}
@@ -58,35 +72,20 @@
 </div>
 {% endblock %}
 
-
-{% macro forum_stats(forum) -%}
-{% if forum.last_thread_id and not forum.attr('hidethread') -%}
-  {% trans count=forum.posts, posts=fancy_number(forum.posts, forum.posts_delta), thread=forum_thread(forum) -%}
-  {{ posts }} post - last in {{ thread }}
-  {%- pluralize -%}
-  {{ posts }} posts - last in {{ thread }}
-  {%- endtrans %}
-{%- else -%}
-  {% trans count=forum.posts, posts=fancy_number(forum.posts, forum.posts_delta) -%}
-  {{ posts }} post
-  {%- pluralize -%}
-  {{ posts }} posts
-  {%- endtrans %}
-{%- endif %}
-{%- endmacro %}
-
-{% macro forum_thread(forum) -%}
-<a href="{% url 'thread_new' thread=forum.last_thread_id, slug=forum.last_thread_slug %}">{{ forum.last_thread_name }}</a>
-{%- endmacro %}
-
-{% macro redirect_stats(forum) -%}
-{% trans count=forum.redirects, redirects=fancy_number(forum.redirects, forum.redirects_delta) -%}
-{{ redirects }} click
-{%- pluralize -%}
-{{ redirects }} clicks
-{%- endtrans %}
-{%- endmacro %}
-
-{% macro fancy_number(number, delta) -%}
-<strong{% if delta < number %} class="stat-increment"{% endif %}>{{ number|intcomma }}</strong>
-{%- endmacro %}
+{% block javascripts -%}{{ super() }}
+  <script type="text/javascript">
+    $(function () {
+      function populateForumTooltip(target) {
+        return $('#forum-' + target + ' .forum-meta').html();
+      };
+      {% for category in forums_list %}{% for forum in category.subforums %}
+        $('#forum-{{ forum.id }} .forum-title').tooltip({
+          template: '<div class="tooltip forum-meta-tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',
+          placement: 'right',
+          html: true,
+          title: populateForumTooltip({{ forum.id }})
+        });
+      {% endfor %}{% endfor %}
+    });
+  </script>
+{%- endblock %}

+ 42 - 43
templates/cranefly/index.html

@@ -20,18 +20,32 @@
             {% for forum in category.subforums %}
             <tr>
               <td class="forum-icon"><span class="forum-icon-wrap{% if forum.type == 'redirect' %} forum-icon-redirect{% elif not forum.is_read %} forum-icon-new{% endif %}"><i class="icon-{% if forum.type == 'redirect' %}circle-arrow-right{% else %}comment{% endif %} icon-white"></i></span></td>
-              <td class="forum-main">
-                <h3{% if not forum.is_read %} class="forum-title-new"{% endif %}><a href="{{ forum.type|url(slug=forum.slug, forum=forum.id) }}">{{ forum.name }}</a></h3>
-                {% if forum.show_details %}
+              <td id="forum-{{ forum.id }}" class="forum-main">
+                <h3 class="forum-title{% if not forum.is_read %} forum-title-new{% endif %}"><a href="{{ forum.type|url(slug=forum.slug, forum=forum.id) }}">{{ forum.name }}</a></h3>
+                {% if forum.show_details and forum.type != 'redirect' %}
                 <div class="forum-details">
-                  {% if forum.type == 'redirect' %}
-                  {{ redirect_stats(forum) }}
-                  {% else %}
-                  {{ forum_stats(forum) }}
-                  {% endif %}
+                  {% if acl.forums.can_browse(forum) and (acl.threads.can_read_threads(forum) == 2 or (acl.threads.can_read_threads(forum) == 1 and forum.last_poster_id == user.pk)) %}
+                  {% if forum.last_thread_id -%}
+                  {% trans %}Latest{% endtrans %}: <a href="{% url 'thread_new' thread=forum.last_thread_id, slug=forum.last_thread_slug %}" class="tooltip-top" title="{{ forum.last_thread_name }}">{{ forum.last_thread_name|short_string(28) }}</a>
+                  {%- else -%}
+                  <em>{% trans %}This forum is empty{% endtrans %}</em>
+                  {%- endif %}
+                  {%- else -%}
+                  <em>{% trans %}This forum is protected{% endtrans %}</em>
+                  {%- endif %}
                 </div>
                 {% endif %}
-                {% if forum.description %}<p class="forum-description">{{ forum.description }}</p>{% endif %}
+                <div class="hide forum-meta">
+                  {% if forum.description %}<p class="forum-description">{{ forum.description }}</p>{% endif %}
+                  <div class="forum-stats">
+                    {% if forum.type != 'redirect' %}
+                    <span>{% trans %}Posts{% endtrans %}: <strong>{{ forum.posts|intcomma }}</strong></span>
+                    {% trans %}Threads{% endtrans %}: <strong>{{ forum.threads|intcomma }}</strong>
+                    {% else %}
+                    {% trans %}Clicks{% endtrans %}: <strong>{{ forum.redirects|intcomma }}</strong>
+                    {% endif %}
+                  </div>
+                </div>
               </td>
             </tr>
             {% endfor %}
@@ -52,7 +66,7 @@
         <ul class="unstyled">
           {% for online in rank.online %}
           <li>
-            <img src="{{ online.get_avatar(48) }}" alt="" class="avatar-small">
+            <img src="{{ online.get_avatar(24) }}" alt="" class="avatar-small">
             <a href="{% url 'user' username=online.username_slug, user=online.pk %}">{{ online.username }}</a>
             {% if rank.title or online.title %}<span class="label">{% if online.title %}{{ online.title }}{% else %}{{ _(rank.title) }}{% endif %}</span>{% endif %}
           </li>
@@ -69,7 +83,7 @@
       <ul class="unstyled">
         {% for thread in popular_threads %}
         <li>
-          <a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}" class="index-popular-thread">{{ thread.name }}</a>
+          <a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}" class="index-popular-thread">{{ thread.name|short_string(38) }}</a>
           <div class="muted"><a href="{% url 'forum' forum=thread.forum_id, slug=thread.forum_slug %}">{{ thread.forum_name }}</a> - {{ thread.last|reltimesince }}</div>
         </li>
         {% endfor %}
@@ -105,35 +119,20 @@
 </div>
 {% endblock %}
 
-
-{% macro forum_stats(forum) -%}
-{% if forum.last_thread_id and not forum.attr('hidethread') -%}
-  {% trans count=forum.posts, posts=fancy_number(forum.posts, forum.posts_delta), thread=forum_thread(forum) -%}
-  {{ posts }} post - last in {{ thread }}
-  {%- pluralize -%}
-  {{ posts }} posts - last in {{ thread }}
-  {%- endtrans %}
-{%- else -%}
-  {% trans count=forum.posts, posts=fancy_number(forum.posts, forum.posts_delta) -%}
-  {{ posts }} post
-  {%- pluralize -%}
-  {{ posts }} posts
-  {%- endtrans %}
-{%- endif %}
-{%- endmacro %}
-
-{% macro forum_thread(forum) -%}
-<a href="{% url 'thread_new' thread=forum.last_thread_id, slug=forum.last_thread_slug %}">{{ forum.last_thread_name }}</a>
-{%- endmacro %}
-
-{% macro redirect_stats(forum) -%}
-{% trans count=forum.redirects, redirects=fancy_number(forum.redirects, forum.redirects_delta) -%}
-{{ redirects }} click
-{%- pluralize -%}
-{{ redirects }} clicks
-{%- endtrans %}
-{%- endmacro %}
-
-{% macro fancy_number(number, delta) -%}
-<strong{% if delta < number %} class="stat-increment"{% endif %}>{{ number|intcomma }}</strong>
-{%- endmacro %}
+{% block javascripts -%}{{ super() }}
+  <script type="text/javascript">
+    $(function () {
+      function populateForumTooltip(target) {
+        return $('#forum-' + target + ' .forum-meta').html();
+      };
+      {% for category in forums_list %}{% for forum in category.subforums %}
+        $('#forum-{{ forum.id }} .forum-title').tooltip({
+          template: '<div class="tooltip forum-meta-tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',
+          placement: 'right',
+          html: true,
+          title: populateForumTooltip({{ forum.id }})
+        });
+      {% endfor %}{% endfor %}
+    });
+  </script>
+{%- endblock %}

+ 26 - 3
templates/cranefly/layout.html

@@ -4,7 +4,27 @@
 {% block body %}
 <div id="wrap">
 
-  <div class="navbar navbar-static-top">
+  {#{% if acl.special.can_use_mcp() %}
+  <div class="navbar navbar-inverse navbar-modbar navbar-static-top">
+    <div class="navbar-inner">
+      <div class="container">
+        <ul class="nav">
+          <li><a href="#">Frontlines</a></li>
+          <li><a href="#">Reports <span class="label label-important">5</span></a></li>
+          <li><a href="#">Warnings</a></li>
+        </ul>
+        <form class="navbar-form pull-right">
+          <div class="navbar-search-form">
+            <input type="text" class="span2" placeholder="{% trans %}Enter IP Address or Username...{% endtrans %}">
+            <button type="submit" class="btn btn-link"><i class="icon-search"></i></button>
+          </div>
+        </form>
+      </div>
+    </div>
+  </div>
+  {% endif %}#}
+
+  <div class="navbar navbar-header navbar-static-top">
     <div class="navbar-inner">
       <div class="container">
         <a href="{% url 'index' %}" class="brand">{% if settings.board_header %}{{ settings.board_header }}{% else %}{{ settings.board_name }}{% endif %}</a>
@@ -20,7 +40,9 @@
           <li><a href="{% url 'index' %}" title="{% trans %}Forum Home{% endtrans %}" class="tooltip-bottom"><i class="icon-th-list"></i></a></li>
           <li><a href="{% url 'popular_threads' %}" title="{% trans %}Popular Threads{% endtrans %}" class="hot tooltip-bottom"><i class="icon-fire"></i></a></li>
           <li><a href="{% url 'new_threads' %}" title="{% trans %}New Threads{% endtrans %}" class="fresh tooltip-bottom"><i class="icon-leaf"></i></a></li>{% if not user.crawler %}
+          {% if not user.is_crawler() %}
           <li><a href="#" title="{% trans %}Search Community{% endtrans %}" class="tooltip-bottom"><i class="icon-search"></i></a></li>{% endif %}
+          {% endif %}
           <li><a href="{% url 'users' %}" title="{% trans %}Browse Users{% endtrans %}" class="tooltip-bottom"><i class="icon-user"></i></a></li>
           {% if settings.tos_url or settings.tos_content %}<li><a href="{% if settings.tos_url %}{{ settings.tos_url }}{% else %}{% url 'tos' %}{% endif %}" title="{% if settings.tos_title %}{{ settings.tos_title }}{% else %}{% trans %}Forum Terms of Service{% endtrans %}{% endif %}" class="tooltip-bottom"><i class="icon-certificate"></i></a></li>{% endif %}
         </ul>
@@ -28,9 +50,10 @@
         {% if user.is_authenticated() %}
         <ul class="nav navbar-blocks pull-right">
           <li class="user-profile"><a href="{% url 'user' user=user.id, username=user.username_slug %}" title="{% trans %}Go to your profile{% endtrans %}" class="tooltip-bottom"><div><img src="{{ user.get_avatar(28) }}" alt=""> {{ user.username }}</div></a></li>
-          {#<li><a href="#" title="{% trans %}Active Reports{% endtrans %}" class="tooltip-bottom"><i class="icon-warning-sign"></i><span class="stat">5</span></a></li>#}
           <li><a href="{% url 'alerts' %}" title="{% if user.alerts %}{% trans %}You have new notifications!{% endtrans %}{% else %}{% trans %}Your Notifications{% endtrans %}{% endif %}" class="tooltip-bottom"><i class="icon-asterisk"></i>{% if user.alerts %}<span class="label label-important">{{ user.alerts }}</span>{% endif %}</a></li>
-          {#<li><a href="#" title="{% trans %}Private messages{% endtrans %}" class="tooltip-bottom"><i class="icon-inbox"></i><span class="stat">2</span></a></li>#}
+          {% if settings.enable_private_threads and acl.forums.can_browse(private_threads) and acl.threads.can_read_threads(private_threads) %}
+          <li><a href="{% url 'private_threads' %}" title="{% if user.unread_pds %}{% trans %}There are unread Private Threads!{% endtrans %}{% else %}{% trans %}Your Private Threads{% endtrans %}{% endif %}" class="tooltip-bottom"><i class="icon-inbox"></i>{% if user.unread_pds %}<span class="label label-important">{{ user.unread_pds }}</span>{% endif %}</a></li>
+          {% endif %}
           <li><a href="{% url 'newsfeed' %}" title="{% trans %}Your News Feed{% endtrans %}" class="tooltip-bottom"><i class="icon-signal"></i></a></li>
           <li><a href="{% url 'watched_threads' %}" title="{% trans %}Threads you are watching{% endtrans %}" class="tooltip-bottom"><i class="icon-bookmark"></i></a></li>
           <li><a href="{% url 'usercp' %}" title="{% trans %}Edit your profile options{% endtrans %}" class="tooltip-bottom"><i class="icon-cog"></i></a></li>

+ 5 - 1
templates/cranefly/macros.html

@@ -25,7 +25,7 @@
   </div>
 {%- endmacro %}
 
-{# Render single message #}
+{# Render icon #}
 {% macro draw_message_icon(message) -%}
   	<div class="alert-icon"><span><i class="icon-{% if message.type == 'error' -%}remove
   		{%- elif message.type == 'success' -%}ok
@@ -39,4 +39,8 @@
 {%- trans current_page=('<strong>' ~ pagination['page'] ~ '</strong>')|safe, pages=('<strong>' ~ pagination['total'] ~ '</strong>')|safe -%}
     Page {{ current_page }} of {{ pages }}
 {%- endtrans -%}
+{%- endmacro %}
+
+{% macro itemprop_bread() -%}
+itemprop="breadcrumb"
 {%- endmacro %}

+ 2 - 2
templates/cranefly/new_threads.html

@@ -36,7 +36,7 @@
             {%- endif %}"><i class="icon-comment"></i></a>
             <a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}" class="thread-name">{{ thread.name }}</a>
             <span class="thread-details">
-              {% trans user=thread_starter(thread), forum=thread_forum(thread), start=thread.start|reldate|low %}by {{ user }} in {{ forum }} {{ start }}{% endtrans %}
+              {% trans user=thread_starter(thread), forum=thread_forum(thread), start=thread.start|reltimesince|low %}by {{ user }} in {{ forum }} {{ start }}{% endtrans %}
             </span>
             <ul class="unstyled thread-flags">
               {% if thread.weight == 2 %}
@@ -57,7 +57,7 @@
           </td>
           <td>
             <div class="thread-last-reply">
-              {{ replies(thread.replies) }} - {% trans user=thread_reply(thread), last=thread.last|reldate|low %}last by {{ user }} {{ last }}{% endtrans %}
+              {{ replies(thread.replies) }} - {% trans user=thread_reply(thread), last=thread.last|reltimesince|low %}last by {{ user }} {{ last }}{% endtrans %}
             </div>
           </td>
         </tr>

+ 2 - 2
templates/cranefly/newsfeed.html

@@ -23,9 +23,9 @@
       <div class="media-body">
         <a href="{% url 'thread_find' thread=item.thread.pk, slug=item.thread.slug, post=item.pk %}" class="post-preview">{{ item.post_preparsed|markdown_short(300) }}</a>
         <div class="media-footer">{% if item.thread.start_post_id == item.pk -%}
-        {% trans thread=thread(item), forum=forum(item.forum), user=username(item.user), date=item.date|reldate|low %}Thread {{ thread }} posted in {{ forum }} by {{ user }} {{ date }}{% endtrans %}
+        {% trans thread=thread(item), forum=forum(item.forum), user=username(item.user), date=item.date|reltimesince|low %}Thread {{ thread }} posted in {{ forum }} by {{ user }} {{ date }}{% endtrans %}
         {%- else -%}
-        {% trans thread=thread(item), forum=forum(item.forum), user=username(item.user), date=item.date|reldate|low %}Reply to {{ thread }} posted in {{ forum }} by {{ user }} {{ date }}{% endtrans %}
+        {% trans thread=thread(item), forum=forum(item.forum), user=username(item.user), date=item.date|reltimesince|low %}Reply to {{ thread }} posted in {{ forum }} by {{ user }} {{ date }}{% endtrans %}
         {%- endif %}</div>
       </div>
     </div>

+ 2 - 2
templates/cranefly/popular_threads.html

@@ -36,7 +36,7 @@
             {%- endif %}"><i class="icon-comment"></i></a>
             <a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}" class="thread-name">{{ thread.name }}</a>
             <span class="thread-details">
-              {% trans user=thread_starter(thread), forum=thread_forum(thread), start=thread.start|reldate|low %}by {{ user }} in {{ forum }} {{ start }}{% endtrans %}
+              {% trans user=thread_starter(thread), forum=thread_forum(thread), start=thread.start|reltimesince|low %}by {{ user }} in {{ forum }} {{ start }}{% endtrans %}
             </span>
             <ul class="unstyled thread-flags">
               {% if thread.weight == 2 %}
@@ -57,7 +57,7 @@
           </td>
           <td>
             <div class="thread-last-reply">
-              {{ replies(thread.replies) }} - {% trans user=thread_reply(thread), last=thread.last|reldate|low %}last by {{ user }} {{ last }}{% endtrans %}
+              {{ replies(thread.replies) }} - {% trans user=thread_reply(thread), last=thread.last|reltimesince|low %}last by {{ user }} {{ last }}{% endtrans %}
             </div>
           </td>
         </tr>

+ 81 - 0
templates/cranefly/private_threads/changelog.html

@@ -0,0 +1,81 @@
+{% extends "cranefly/layout.html" %}
+{% import "cranefly/macros.html" as macros with context %}
+
+{% block title %}{{ macros.page_title(title=(_("Post #%(post)s Changelog") % {'post': post.pk}),parent=thread.name) }}{% endblock %}
+
+{% block breadcrumb %}{{ super() }} <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li><a href="{% url 'private_threads' %}">{% trans %}Private Threads{% endtrans %}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li><a href="{% url 'private_thread' thread=thread.pk, slug=thread.slug %}">{{ thread.name }}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li class="active">{% trans post=post.pk %}Post #{{ post }} Changelog{% endtrans %}
+{%- endblock %}
+
+{% block container %}
+<div class="page-header header-primary">
+  <div class="container">
+    {{ messages_list(messages) }}
+    <ul class="breadcrumb">
+      {{ self.breadcrumb() }}</li>
+    </ul>
+    <h1>{% trans post=post.pk %}Post #{{ post }} Changelog{% endtrans %} <small>{{ thread.name }}</small></h1>
+    <ul class="unstyled header-stats">
+      <li><i class="icon-time"></i> <a href="{% url 'private_thread_find' thread=thread.pk, slug=thread.slug, post=post.pk %}">{{ post.date|reltimesince }}</a></li>
+      <li><i class="icon-user"></i> {% if post.user %}<a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}">{{ post.user.username }}</a>{% else %}{{ post.user_name }}{% endif %}</li>
+      <li><i class="icon-pencil"></i> {% trans edits=post.edits %}One edit{% pluralize %}{{ edits }} edits{% endtrans %}</li>
+      {% if post.protected %}<li><i class="icon-lock"></i> {% trans %}Protected{% endtrans %}</li>{% endif %}
+    </ul>
+  </div>
+</div>
+
+<div class="container container-primary">
+  <div class="post-changelog">
+    {% if edits %}
+    <table class="table table-striped">
+      <thead>
+        <tr>
+          <th style="width: 1%;">&nbsp;</th>
+          <th>{% trans %}Change Log{% endtrans %}</th>
+        </tr>
+      </thead>
+      <tbody>
+        {% for edit in edits %}
+        <tr>
+          <td>
+            <span class="change-{% if edit.change > 0 %}added{% elif edit.change < 0 %}removed{% else %}none{% endif %}{% if not edit.reason %} change-small{% endif %}">
+              {% if edit.change > 0 %}+{% endif %}{{ edit.change }}
+            </span>
+          </td>
+          <td>
+            <a href="{% url 'private_thread_changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=edit.pk %}" class="change-no">#{{ loop.revindex }}</a>
+            {% if edit.reason %}
+            <div class="change-reason">
+              <a href="{% url 'private_thread_changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=edit.pk %}">{{ edit.reason }}</a>
+            </div>{% endif %}
+            <div class="change-description">
+              <a href="{% url 'private_thread_changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=edit.pk %}">
+              {% if edit.change != 0 %}{% if edit.change > 0 -%}
+              {% trans chars=edit.change %}Added one character to post.{% pluralize %}Added {{ chars }} characters to post.{% endtrans %}
+              {%- elif edit.change < 0 -%}
+              {% trans chars=edit.change|abs %}Removed one character from post.{% pluralize %}Removed {{ chars }} characters from post.{% endtrans %}
+              {%- else -%}
+              {% trans %}No change in message's length.{% endtrans %}
+              {%- endif %}{% endif %}{% if edit.thread_name_old %} {% trans old=edit.thread_name_old, new=edit.thread_name_new %}Changed thread name from "{{ old }}" to "{{ new }}".{% endtrans %}{% endif %}{% if edit.thread_name_old %} {% trans old=edit.thread_name_old, new=edit.thread_name_new %}Renamed thread from "{{ old }}" to "{{ new }}".{% endtrans %}{% endif %}</a>
+              <span class="change-details">
+                {% trans user=edit_user(edit), date=edit.date|reldate|low %}By {{ user }} {{ date }}{% endtrans %}
+              </span>
+            </div>
+          </td>
+        </tr>
+        {% endfor %}
+      </tbody>
+    </table>
+    {% else %}
+    <p class="lead">{% trans %}This post was never edited.{% endtrans %}</p>
+    {% endif %}
+  </div>
+</div>
+{% endblock %}
+
+
+{% macro edit_user(edit) -%}
+{% if edit.user_id %}<a href="{% url 'user' user=edit.user_id, username=edit.user_slug %}">{{ edit.user_name }}</a>{% else %}{{ edit.user_name }}{% endif %}
+{%- endmacro %}

+ 95 - 0
templates/cranefly/private_threads/changelog_diff.html

@@ -0,0 +1,95 @@
+{% extends "cranefly/layout.html" %}
+{% import "cranefly/macros.html" as macros with context %}
+
+{% block title %}{{ macros.page_title(title=(_("Post #%(post)s Changelog") % {'post': post.pk}),parent=thread.name) }}{% endblock %}
+
+{% block breadcrumb %}{{ super() }} <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li><a href="{% url 'private_threads' %}">{% trans %}Private Threads{% endtrans %}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li><a href="{% url 'private_thread' thread=thread.pk, slug=thread.slug %}">{{ thread.name }}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li><a href="{% url 'private_thread_changelog' thread=thread.pk, slug=thread.slug, post=post.pk %}">{% trans post=post.pk %}Post #{{ post }} Changelog{% endtrans %}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li class="active">{% trans date=change.date|reltimesince|low %}Edit from {{ date }}{% endtrans %}
+{%- endblock %}
+
+{% block container %}
+<div class="page-header header-primary">
+  <div class="container">
+    {{ messages_list(messages) }}
+    <ul class="breadcrumb">
+      {{ self.breadcrumb() }}</li>
+    </ul>
+    <h1>{% trans date=change.date|reltimesince|low %}Edit from {{ date }}{% endtrans %} <small>{% trans post=post.pk %}Post #{{ post }} Changelog{% endtrans %}</small></h1>
+    <ul class="unstyled header-stats pull-left">
+      <li><i class="icon-time"></i> <a href="{% url 'private_thread_find' thread=thread.pk, slug=thread.slug, post=post.pk %}">{{ post.date|reltimesince }}</a></li>
+      <li><i class="icon-user"></i> {% if change.user_id %}<a href="{% url 'user' user=change.user_id, username=change.user_slug %}">{{ change.user_name }}</a>{% else %}{{ change.user_name }}{% endif %}</li>
+      {% if acl.users.can_see_users_trails() %}
+      <li><i class="icon-globe"></i> {{ change.ip }}</li>
+      <li><i class="icon-qrcode"></i> {{ change.agent }}</li>
+      {% endif %}
+      {% if change.change != 0 %}<li><i class="icon-{% if change.change > 0 %}plus{% elif change.change < 0 %}minus{% endif %}"></i> {% if change.change > 0 -%}
+      {% trans chars=change.change %}Added one character{% pluralize %}Added {{ chars }} characters{% endtrans %}
+      {%- elif change.change < 0 -%}
+      {% trans chars=change.change|abs %}Removed one character{% pluralize %}Removed {{ chars }} characters{% endtrans %}
+      {%- endif %}</li>{% endif %}
+    </ul>
+  </div>
+</div>
+
+<div class="container container-primary">
+  <div class="post-diff">
+    {% if message %}
+    <div class="messages-list">
+      {{ macros.draw_message(message) }}
+    </div>
+    {% endif %}
+
+    {% if change.reason %}
+    <p class="lead">{{ change.reason }}</p>
+    {% endif %}
+
+    {% if acl.threads.can_edit_reply(user, forum, thread, post) or prev or next %}
+    <div class="diff-extra">
+      {{ pager() }}
+      {% if user.is_authenticated() and acl.threads.can_make_revert(forum, thread) %}
+      <form class="form-inline pull-right" action="{% url 'private_thread_changelog_revert' thread=thread.pk, slug=thread.slug, post=post.pk, change=change.pk %}" method="post">
+        <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+        <button type="submit" class="btn btn-danger">{% trans %}Revert this edit{% endtrans %}</button></li>
+      </form>
+      {%- endif %}
+    </div>
+    {% endif %}
+
+    <div class="post-diff-details">
+      <table>
+        <tbody>
+          {% for line in diff %}{% if line[0] != "?" %}
+          <tr>
+            <td class="line"><a href="#{{ l }}">{{ l }}.</a></td>
+            <td class="{% if line[0] == '+' %}added{% elif line[0] == '-' %}removed{% else %}stag{% endif %}{% if l is even %} even{% endif %}">{% if line[2:] %}{{ line[2:] }}{% else %}&nbsp;{% endif %}</td>
+          </tr>
+          {% set l = l + 1 %}
+          {% endif %}{% endfor %}
+        </tbody>
+      </table>
+    </div>
+
+    {% if prev or next %}
+    <div class="diff-extra">
+      {{ pager() }}
+    </div>
+    {% endif %}
+
+  </div>
+</div>
+{% endblock %}
+
+
+{% macro pager() %}
+{% if prev or prev %}
+<div class="pagination pull-left">
+  <ul>
+    {% if prev %}<li><a href="{% url 'private_thread_changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=prev.pk %}"><i class="icon-chevron-left"></i> {{ prev.date|reldate }}</a></li>{% endif %}
+    {% if next %}<li><a href="{% url 'private_thread_changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=next.pk %}">{{ next.date|reldate }} <i class="icon-chevron-right"></i></a></li>{% endif %}
+  </ul>
+</div>
+{% endif %}
+{% endmacro %}

+ 35 - 0
templates/cranefly/private_threads/details.html

@@ -0,0 +1,35 @@
+{% extends "cranefly/layout.html" %}
+{% import "cranefly/macros.html" as macros with context %}
+
+{% block title %}{{ macros.page_title(title=(_("Post #%(post)s Info") % {'post': post.pk}),parent=thread.name) }}{% endblock %}
+
+{% block breadcrumb %}{{ super() }} <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li><a href="{% url 'private_threads' %}">{% trans %}Private Threads{% endtrans %}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li><a href="{% url 'private_thread' thread=thread.pk, slug=thread.slug %}">{{ thread.name }}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li class="active">{% trans post=post.pk %}Post #{{ post }} Info{% endtrans %}
+{%- endblock %}
+
+{% block container %}
+<div class="page-header header-primary">
+  <div class="container">
+    {{ messages_list(messages) }}
+    <ul class="breadcrumb">
+      {{ self.breadcrumb() }}</li>
+    </ul>
+    <h1>{% trans post=post.pk %}Post #{{ post }} Info{% endtrans %} <small>{{ thread.name }}</small></h1>
+    <ul class="unstyled header-stats">
+      <li><i class="icon-time"></i> <a href="{% url 'private_thread_find' thread=thread.pk, slug=thread.slug, post=post.pk %}">{{ post.date|reltimesince }}</a></li>
+      <li><i class="icon-user"></i> {% if post.user %}<a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}">{{ post.user.username }}</a>{% else %}{{ post.user_name }}{% endif %}</li>
+      <li><i class="icon-pencil"></i> {% trans edits=post.edits %}One edit{% pluralize %}{{ edits }} edits{% endtrans %}</li>
+      {% if post.protected %}<li><i class="icon-lock"></i> {% trans %}Protected{% endtrans %}</li>{% endif %}
+    </ul>
+  </div>
+</div>
+
+<div class="container container-primary">
+  <h2>{% trans %}IP Address{% endtrans %}</h2>
+  <p class="lead">{{ post.ip }}</p>
+  <h2>{% trans %}UserAgent{% endtrans %}</h2>
+  <p class="lead">{{ post.agent }}</p>
+</div>
+{% endblock %}

+ 184 - 0
templates/cranefly/private_threads/list.html

@@ -0,0 +1,184 @@
+{% extends "cranefly/layout.html" %}
+{% import "_forms.html" as form_theme with context %}
+{% import "cranefly/macros.html" as macros with context %}
+
+{% block title %}{{ macros.page_title(title=_("Private Threads"),page=pagination['page']) }}{% endblock %}
+
+{% block breadcrumb %}{{ super() }} <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li class="active">{% trans %}Private Threads{% endtrans %}
+{%- endblock %}
+
+{% block container %}
+<div class="page-header header-primary">
+  <div class="container">
+    {{ messages_list(messages) }}
+    <ul class="breadcrumb" {{ macros.itemprop_bread() }}>
+      {{ self.breadcrumb() }}</li>
+    </ul>
+    <h1>{% trans %}Private Threads{% endtrans %}</h1>
+  </div>
+</div>
+
+<div class="container container-primary">
+
+  {% if message %}
+  <div class="messages-list">
+    {{ macros.draw_message(message) }}
+  </div>
+  {% endif %}
+
+  <div class="forum-threads-extra extra-top">
+    {{ pager() }}
+    {% if acl.threads.can_start_threads(forum) %}
+    <a href="{% url 'private_thread_start' %}" class="btn btn-inverse pull-right"><i class="icon-plus"></i> {% trans %}New Thread{% endtrans %}</a>
+    {% endif %}
+  </div>
+
+  <div class="forum-threads-list">
+    <table class="table">
+      <thead>
+        <tr>
+          <th>{% trans %}Thread{% endtrans %}</th>
+          <th class="span1">{% trans %}Rating{% endtrans %}</th>
+          <th class="span5">{% trans %}Activity{% endtrans %}</th>
+          {% if list_form %}
+          <th class="check-cell"><label class="checkbox"><input type="checkbox" class="checkbox-master"></label></th>
+          {% endif %}
+        </tr>
+      </thead>
+      <tbody>
+        {% for thread in threads %}
+        <tr>
+          <td>
+            <a href="{% url 'private_thread_new' thread=thread.pk, slug=thread.slug %}" class="thread-icon{% if not thread.is_read %} thread-new{% endif %} tooltip-top" title="{% if not thread.is_read -%}
+            {% trans %}Click to see first unread post{% endtrans %}
+            {%- else -%}
+            {% trans %}Click to see last post{% endtrans %}
+            {%- endif %}"><i class="icon-comment"></i></a>
+            <a href="{% url 'private_thread' thread=thread.pk, slug=thread.slug %}" class="thread-name">{{ thread.name }}</a>
+            <span class="thread-details">
+              {% trans user=thread_starter(thread), start=thread.start|reldate|low %}by {{ user }} {{ start }}{% endtrans %}
+            </span>
+            <ul class="unstyled thread-flags">
+              {% if thread.replies_reported %}
+              <li><i class="icon-warning-sign tooltip-top" title="{% trans %}This thread has reported replies{% endtrans %}"></i></li>
+              {% endif %}
+              {% if thread.replies_moderated %}
+              <li><i class="icon-question-sign tooltip-top" title="{% trans %}This thread has unreviewed replies{% endtrans %}"></i></li>
+              {% endif %}
+              {% if thread.weight == 2 %}
+              <li><i class="icon-star tooltip-top" title="{% trans %}This thread is an annoucement{% endtrans %}"></i></li>
+              {% endif %}
+              {% if thread.weight == 1 %}
+              <li><i class="icon-star-empty tooltip-top" title="{% trans %}This thread is sticky{% endtrans %}"></i></li>
+              {% endif %}
+              {% if thread.moderated  %}
+              <li><i class="icon-eye-close tooltip-top" title="{% trans %}This thread awaits review{% endtrans %}"></i></li>
+              {% endif %}
+              {% if thread.deleted %}
+              <li><i class="icon-trash tooltip-top" title="{% trans %}This thread is deleted{% endtrans %}"></i></li>
+              {% endif %}
+              {% if thread.closed %}
+              <li><i class="icon-lock tooltip-top" title="{% trans %}This thread is closed{% endtrans %}"></i></li>
+              {% endif %}
+            </ul>
+          </td>
+          <td>
+            <div class="thread-rating{% if (thread.upvotes-thread.downvotes) > 0 %} thread-rating-positive{% elif (thread.upvotes-thread.downvotes) < 0 %} thread-rating-negative{% endif %}">
+              {% if (thread.upvotes-thread.downvotes) > 0 %}+{% elif (thread.upvotes-thread.downvotes) < 0 %}-{% endif %}{{ (thread.upvotes-thread.downvotes)|abs|intcomma }}
+            </div>
+          </td>
+          <td>
+            <div class="thread-last-reply">
+              {{ replies(thread.replies) }} - {% trans user=thread_reply(thread), last=thread.last|reltimesince|low %}last by {{ user }} {{ last }}{% endtrans %}
+            </div>
+          </td>
+          {% if list_form %}
+          <td class="check-cell">{% if thread.forum_id == forum.pk %}<label class="checkbox"><input form="threads_form" name="{{ list_form['list_items']['html_name'] }}" type="checkbox" class="checkbox-member" value="{{ thread.pk }}"{% if list_form['list_items']['has_value'] and ('' ~ thread.pk) in list_form['list_items']['value'] %} checked="checked"{% endif %}></label>{% else %}&nbsp;{% endif %}</td>
+          {% endif %}
+        </tr>
+        {% else %}
+        <tr>
+          <td colspan="4" class="threads-list-empty">
+            {% if tab == 'all' %}
+            {% trans %}You are not participating in any private discussions.{% endtrans %}
+            {% elif tab == 'new' %}
+            {% trans %}There are no unread private threads.{% endtrans %}
+            {% else %}
+            {% trans %}You have started no private threads.{% endtrans %}
+            {% endif %}
+          </td>
+        </tr>
+        {% endfor %}
+      </tbody>
+    </table>
+    {% if list_form %}
+    <div class="threads-actions">
+      <form id="threads_form" class="form-inline pull-right" action="{% url 'private_threads' %}" method="POST">
+        <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+        {{ form_theme.input_select(list_form['list_action'],width=3) }}
+        <button type="submit" class="btn btn-danger">{% trans %}Go{% endtrans %}</button>
+      </form>
+    </div>
+    {% endif %}
+  </div>
+
+  <div class="forum-threads-extra">
+    {{ pager() }}
+    {% if acl.threads.can_start_threads(forum) %}
+    <a href="{% url 'private_thread_start' %}" class="btn btn-inverse pull-right"><i class="icon-plus"></i> {% trans %}New Thread{% endtrans %}</a>
+    {% endif %}
+  </div>
+
+</div>
+{% endblock %}
+
+
+{% macro replies(thread_replies) -%}
+{% trans count=thread_replies, replies=('<strong>' ~ (thread_replies|intcomma) ~ '</strong>')|safe -%}
+{{ replies }} reply
+{%- pluralize -%}
+{{ replies }} replies
+{%- endtrans %}
+{%- endmacro %}
+
+{% macro thread_starter(thread) -%}
+{% if thread.start_poster_id %}<a href="{% url 'user' user=thread.start_poster_id, username=thread.start_poster_slug %}" class="user-link">{{ thread.start_poster_name }}</a>{% else %}{{ thread.start_poster_name }}{% endif %}
+{%- endmacro %}
+
+{% macro thread_reply(thread) -%}
+{% if thread.last_poster_id %}<a href="{% url 'user' user=thread.last_poster_id, username=thread.last_poster_slug %}" class="user-link">{{ thread.last_poster_name }}</a>{% else %}{{ thread.last_poster_name }}{% endif %}
+{%- endmacro %}
+
+{% macro pager() %}
+{% if pagination['total'] > 0 %}
+<div class="pagination pull-left">
+  <ul>
+    <li class="count">{{ macros.pager_label(pagination) }}</li>
+    {%- if pagination['prev'] > 1 %}<li><a href="{% url 'private_threads' %}" class="tooltip-top" title="{% trans %}First Page{% endtrans %}"><i class="icon-chevron-left"></i> {% trans %}First{% endtrans %}</a></li>{% endif -%}
+    {%- if pagination['prev'] > 0 %}<li><a href="{%- if pagination['prev'] > 1 %}{% url 'private_threads' page=pagination['prev'] %}{% else %}{% url 'private_threads' %}{% endif %}" class="tooltip-top" title="{% trans %}Newest Threads{% endtrans %}"><i class="icon-chevron-left"></i></a></li>{% endif -%}
+    {%- if pagination['next'] > 0 %}<li><a href="{% url 'private_threads' page=pagination['next'] %}" class="tooltip-top" title="{% trans %}Older Threads{% endtrans %}"><i class="icon-chevron-right"></i></a></li>{% endif -%}
+  </ul>
+</div>
+{% endif %}
+{% endmacro %}
+
+{% block javascripts -%}
+{{ super() }}
+{%- if list_form %}
+  <script type="text/javascript">
+    $(function () {
+      $('#threads_form').submit(function() {
+        if ($('.check-cell[]:checked').length == 0) {
+          alert("{% trans %}You have to select at least one thread.{% endtrans %}");
+          return false;
+        }
+        if ($('#id_list_action').val() == 'hard') {
+          var decision = confirm("{% trans %}Are you sure you want to delete selected threads? This action is not reversible!{% endtrans %}");
+          return decision;
+        }
+        return true;
+      });
+    });
+  </script>{% endif %}
+{%- endblock %}

+ 174 - 0
templates/cranefly/private_threads/posting.html

@@ -0,0 +1,174 @@
+{% extends "cranefly/layout.html" %}
+{% import "_forms.html" as form_theme with context %}
+{% import "cranefly/editor.html" as editor with context %}
+{% import "cranefly/macros.html" as macros with context %}
+
+{% block title %}{% if thread -%}
+{{ macros.page_title(title=_(get_title()), parent=thread.name) }}
+{%- else -%}
+{{ macros.page_title(title=_(get_title()), parent=_("Private Threads")) }}
+{%- endif %}{% endblock %}
+
+{% block breadcrumb %}{{ super() }} <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li><a href="{% url 'private_threads' %}">{% trans %}Private Threads{% endtrans %}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
+{% if thread %}<li><a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}">{{ thread.name }}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>{% endif %}
+<li class="active">{{ get_title() }}
+{%- endblock %}
+
+{% block container %}
+<div class="page-header header-primary">
+  <div class="container">
+    {{ messages_list(messages) }}
+    <ul class="breadcrumb">
+      {{ self.breadcrumb() }}</li>
+    </ul>
+    <h1>{{ get_title() }} <small>{% if thread %}{{ thread.name }}{% else %}{% trans %}Private Threads{% endtrans %}{% endif %}</small></h1>
+    {% if thread %}
+    <ul class="unstyled header-stats">
+      {{ get_info() }}
+    </ul>
+    {% endif %}
+  </div>
+</div>
+<div class="container container-primary">
+  <div class="row">
+    <div class="span8 offset2">
+      <div class="posting">
+        <div class="form-container">
+
+          <div class="form-header">
+            <h1>{{ get_title() }}</h1>
+          </div>
+
+          {% if message %}
+          <div class="messages-list">
+            {{ macros.draw_message(message) }}
+          </div>
+          {% endif %}
+
+          {% if preview %}
+          <div class="form-preview">
+            <div class="markdown js-extra">
+              {{ preview|markdown_final|safe }}
+            </div>
+          </div>
+          {% endif %}
+
+          <form action="{{ get_action() }}" method="post">
+            <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+            {% if 'thread_name' in form.fields %}
+            {{ form_theme.row_widget(form.fields.thread_name, width=8) }}
+            {% endif %}
+            {% if action == 'new_thread' and 'invite_users' in form.fields %}
+            {{ form_theme.row_widget(form.fields.invite_users, width=8) }}
+            {% endif %}
+            {% if 'thread_name' in form.fields or (action == 'new_thread' and 'invite_users' in form.fields) %}
+            <hr>
+            <h4>Message Body</h4>
+            {% endif %}
+            {{ editor.editor(form.fields.post, get_button(), rows=8, extra=get_extra()) }}
+            {% if 'edit_reason' in form.fields or (action == 'new_reply' and 'invite_users' in form.fields) %}
+            <hr>
+            {% if action == 'new_reply' and 'invite_users' in form.fields %}
+            {{ form_theme.row_widget(form.fields.invite_users, width=8) }}
+            {% endif %}
+            {% if 'edit_reason' in form.fields %}
+            {{ form_theme.row_widget(form.fields.edit_reason, width=8) }}
+            {% endif %}
+
+            <div class="form-actions">
+              <button type="submit" class="btn btn-primary">{{ get_button() }}</button>
+              <button id="editor-preview" name="preview" type="submit" class="btn">{% trans %}Preview{% endtrans %}</button>
+            </div>
+            {% endif %}
+          </form>
+
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+{% endblock %}
+
+{% block stylesheets %}{{ super() }}
+<link href="{{ STATIC_URL }}cranefly/highlight/styles/monokai.css" rel="stylesheet">
+{% endblock %}
+
+{% block javascripts %}{{ super() }}
+  <script src="{{ STATIC_URL }}cranefly/highlight/highlight.pack.js"></script>
+  <script type="text/javascript">
+    hljs.tabReplace = '    ';
+    hljs.initHighlightingOnLoad();
+    EnhancePostsMD();
+  </script>
+  {{ editor.js() }}
+{% endblock %}
+
+
+{% macro get_action() -%}
+{% if action == 'new_thread' -%}
+{% url 'private_thread_start' %}
+{%- elif action == 'edit_thread' -%}
+{% url 'private_thread_edit' thread=thread.pk, slug=thread.slug %}
+{%- elif action in 'new_reply' -%}
+{%- if quote -%}
+{% url 'private_thread_reply' thread=thread.pk, slug=thread.slug, quote=quote.pk %}
+{%- else -%}
+{% url 'private_thread_reply' thread=thread.pk, slug=thread.slug %}
+{%- endif -%}
+{%- elif action == 'edit_reply' -%}
+{% url 'post_edit' thread=thread.pk, slug=thread.slug, post=post.pk %}
+{%- endif %}
+{%- endmacro %}
+
+
+{% macro get_title() -%}
+{% if action == 'new_thread' -%}
+{% trans %}Post New Thread{% endtrans %}
+{%- elif action == 'edit_thread' -%}
+{% trans %}Edit Thread{% endtrans %}
+{%- elif action == 'new_reply' -%}
+{% trans %}Post New Reply{% endtrans %}
+{%- elif action == 'edit_reply' -%}
+{% trans %}Edit Reply{% endtrans %}
+{%- endif %}
+{%- endmacro %}
+
+
+{% macro get_info() -%}
+{% if action == 'edit_reply' -%}
+    {% if post.moderated %}<li><i class="icon-eye-close"></i> {% trans %}Not Reviewed{% endtrans %}</li>{% endif %}
+    <li><i class="icon-time"></i> {{ post.date|reltimesince }}</li>
+    <li><i class="icon-user"></i> {% if post.user %}<a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}">{{ post.user.username }}</a>{% else %}{{ post.user_name }}{% endif %}</li>
+    <li><i class="icon-pencil"></i> {% if post.edits > 0 -%}
+      {% trans count=post.edits %}One edit{% pluralize %}{{ count }} edits{% endtrans %}
+    {%- else -%}
+      {% trans %}First edit{% endtrans %}
+    {%- endif %}</li>
+{%- else -%}
+    {% if thread.moderated %}<li><i class="icon-eye-close"></i> {% trans %}Not Reviewed{% endtrans %}</li>{% endif %}
+    <li><i class="icon-time"></i> {{ thread.last|reltimesince }}</li>
+    <li><i class="icon-user"></i> {% if thread.start_poster_id %}<a href="{% url 'user' user=thread.start_poster_id, username=thread.start_poster_slug %}">{{ thread.start_poster_name }}</a>{% else %}{{ thread.start_poster_name }}{% endif %}</li>
+    <li><i class="icon-comment"></i> {% if thread.replies > 0 -%}
+      {% trans count=thread.replies, replies=thread.replies|intcomma %}One reply{% pluralize %}{{ replies }} replies{% endtrans %}
+    {%- else -%}
+      {% trans %}No replies{% endtrans %}
+    {%- endif %}</li>
+{%- endif %}
+{%- endmacro %}
+
+
+{% macro get_button() -%}
+{% if action == 'new_thread' -%}
+{% trans %}Post Thread{% endtrans %}
+{%- elif action == 'new_reply' -%}
+{% trans %}Post Reply{% endtrans %}
+{%- else -%}
+{% trans %}Save Changes{% endtrans %}
+{%- endif %}
+{%- endmacro %}
+
+
+{% macro get_extra() %}
+  <button id="editor-preview" name="preview" type="submit" class="btn pull-right">{% trans %}Preview{% endtrans %}</button>
+{% endmacro %}

+ 541 - 0
templates/cranefly/private_threads/thread.html

@@ -0,0 +1,541 @@
+{% extends "cranefly/layout.html" %}
+{% import "_forms.html" as form_theme with context %}
+{% import "cranefly/editor.html" as editor with context %}
+{% import "cranefly/macros.html" as macros with context %}
+
+{% block title %}{{ macros.page_title(title=thread.name,parent=_("Private Threads"),page=pagination['page']) }}{% endblock %}
+
+{% block breadcrumb %}{{ super() }} <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li><a href="{% url 'private_threads' %}">{% trans %}Private Threads{% endtrans %}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li class="active">{{ thread.name }}
+{%- endblock %}
+
+{% block container %}
+<div class="page-header header-primary">
+  <div class="container">
+    {{ messages_list(messages) }}
+    <ul class="breadcrumb" {{ macros.itemprop_bread() }}>
+      {{ self.breadcrumb() }}</li>
+    </ul>
+    <h1>{{ thread.name }}</h1>
+    <ul class="unstyled header-stats">
+      {% if thread.moderated %}<li><i class="icon-eye-close"></i> {% trans %}Not Reviewed{% endtrans %}</li>{% endif %}
+      <li><i class="icon-time"></i> {{ thread.last|reltimesince }}</li>
+      <li><i class="icon-user"></i> {% if thread.start_poster_id %}<a href="{% url 'user' user=thread.start_poster_id, username=thread.start_poster_slug %}">{{ thread.start_poster_name }}</a>{% else %}{{ thread.start_poster_name }}{% endif %}</li>
+      <li><i class="icon-comment"></i> {% if thread.replies > 0 -%}
+        {% trans count=thread.replies, replies=thread.replies|intcomma %}One reply{% pluralize %}{{ replies }} replies{% endtrans %}
+      {%- else -%}
+        {% trans %}No replies{% endtrans %}
+      {%- endif %}</li>
+      <li class="stats-form">
+        <form class="leave-form" action="{% url 'private_thread_remove_user' thread=thread.pk, slug=thread.slug %}" method="post">
+          <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+          <input type="hidden" name="user" value="{{ user.pk }}">
+          <button type="submit" class="btn"><i class="icon-remove"></i> Leave Thread</button>
+        </form>
+      </li>
+    </ul>
+  </div>
+</div>
+
+<div class="container container-primary">
+  {% if message %}
+  <div class="messages-list">
+    {{ macros.draw_message(message) }}
+  </div>
+  {% endif %}
+
+  <div class="row">
+    <div class="span9">
+
+      <div class="thread-buttons">
+        {{ pager() }} 
+        {% if acl.threads.can_reply(forum, thread) and participants|length > 1 %}
+        <a href="{% url 'private_thread_reply' thread=thread.pk, slug=thread.slug %}" class="btn btn-inverse pull-right"><i class="icon-pencil"></i> {% trans %}Reply{% endtrans %}</a>
+        {% endif %}
+        {% if watcher %}
+        <form action="{% url 'private_thread_unwatch' thread=thread.pk, slug=thread.slug %}" class="form-inline pull-right" method="post"><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"><input type="hidden" name="retreat" value="{{ request_path }}"><button type="submit" class="btn btn-success tooltip-top" title="{% trans %}Remove thread from watched list{% endtrans %}"><i class="icon-bookmark"></i></button></form>
+        {% if watcher.email %}
+        <form action="{% url 'private_thread_unwatch_email' thread=thread.pk, slug=thread.slug %}" class="form-inline pull-right" method="post"><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"><input type="hidden" name="retreat" value="{{ request_path }}"><button type="submit" class="btn btn-success tooltip-top" title="{% trans %}Don't e-mail me anymore if anyone replies to this thread{% endtrans %}"><i class="icon-envelope"></i></button></form>
+        {% else %}
+        <form action="{% url 'private_thread_watch_email' thread=thread.pk, slug=thread.slug %}" class="form-inline pull-right" method="post"><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"><input type="hidden" name="retreat" value="{{ request_path }}"><button type="submit" class="btn tooltip-top" title="{% trans %}E-mail me if anyone replies{% endtrans %}"><i class="icon-envelope"></i></button></form>
+        {% endif %}
+        {% else %}
+        <form action="{% url 'private_thread_watch' thread=thread.pk, slug=thread.slug %}" class="form-inline pull-right" method="post"><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"><input type="hidden" name="retreat" value="{{ request_path }}"><button type="submit" class="btn tooltip-top" title="{% trans %}Add thread to watched list{% endtrans %}"><i class="icon-bookmark"></i></button></form>
+        <form action="{% url 'private_thread_watch_email' thread=thread.pk, slug=thread.slug %}" class="form-inline pull-right" method="post"><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"><input type="hidden" name="retreat" value="{{ request_path }}"><button type="submit" class="btn tooltip-top" title="{% trans %}Add thread to watched list and e-mail me if anyone replies{% endtrans %}"><i class="icon-envelope"></i></button></form>
+        {% endif %}
+        {% if ignored_posts %}
+        <form action="{% url 'private_thread_show_hidden' thread=thread.pk, slug=thread.slug %}" class="form-inline pull-right" method="post"><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"><button type="submit" class="btn"><i class="icon-eye-open"></i> {% trans %}Show Hidden Replies{% endtrans %}</button></form>
+        {% endif %}
+      </div>
+
+      <div class="thread-body">
+        {% for post in posts %}
+        <div id="post-{{ post.pk }}" class="post-wrapper">
+          {% if post.message %}
+          <div class="messages-list">
+            {{ macros.draw_message(post.message) }}
+          </div>
+          {% endif %}
+          {% if post.deleted and not acl.threads.can_see_deleted_posts(forum) %}
+          <div class="post-body post-muted">
+            {% if post.user_id %}
+            <a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}"><img src="{{ post.user.get_avatar(50) }}" alt="" class="user-avatar"></a>
+            {% else %}
+            <img src="{{ macros.avatar_guest(60) }}" alt="" class="user-avatar"></a>
+            {% endif %}
+            <div class="post-content">
+              <div class="post-header">
+                <div class="post-header-compact">
+                  {% if post.user_id %}
+                  <a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}" class="post-author">{{ post.user.username }}</a>{% if post.user.get_title() %} {{ user_label(post.user) }}{% endif %}
+                  {% else %}
+                  <span class="post-author">{{ post.user_name }}</span> <span class="label post-author-label post-label-guest">{% trans %}Unregistered{% endtrans %}</span>
+                  {% endif %}
+                  <span class="separator">&ndash;</span>
+                  <a href="{% if pagination['page'] > 1 -%}
+                  {% url 'private_thread' thread=thread.pk, slug=thread.slug, page=pagination['page'] %}
+                  {%- else -%}
+                  {% url 'private_thread' thread=thread.pk, slug=thread.slug %}
+                  {%- endif %}#post-{{ post.pk }}" class="post-date">{{ post.date|reltimesince }}</a>
+                  {% if post.edits %}
+                  <span class="separator">&ndash;</span>
+                  {% if acl.threads.can_see_changelog(user, forum, post) %}
+                  <a href="{% url 'changelog' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="post-changelog tooltip-bottom" title="{% trans %}Show changelog{% endtrans %}">{% trans count=post.edits %}One edit{% pluralize %}{{ count }} edits{% endtrans %}</a>
+                  {% else %}
+                  <span class="post-changelog">{% trans count=post.edits %}One edit{% pluralize %}{{ count }} edits{% endtrans %}</span>
+                  {% endif %}
+                  {% endif %}
+                </div>
+
+                <a href="{% if pagination['page'] > 1 -%}
+                {% url 'private_thread' thread=thread.pk, slug=thread.slug, page=pagination['page'] %}
+                {%- else -%}
+                {% url 'private_thread' thread=thread.pk, slug=thread.slug %}
+                {%- endif %}#post-{{ post.pk }}" class="post-perma tooltip-left" title="{% trans %}Direct link to this post{% endtrans %}">#{{ pagination['start'] + loop.index }}</a>
+
+                {% if not post.is_read %}
+                <div class="post-extra">
+                  <span class="label label-warning">
+                    {% trans %}New{% endtrans %}
+                  </span>
+                </div>
+                {% endif %}
+
+              </div>
+              <div class="post-message">
+                {% trans user=edit_user(post), date=post.edit_date|reltimesince|low %}{{ user }} has deleted this reply {{ date }}{% endtrans %}
+              </div>
+            </dv>
+          </div>
+          {% elif post.ignored %}
+          <div class="post-body post-muted">
+            <img src="{{ macros.avatar_guest(60) }}" alt="" class="user-avatar"></a>
+            <div class="post-arrow"></div>
+            <div class="post-content">
+              <div class="post-header">
+                <div class="post-header-compact">
+                  <a href="{% if pagination['page'] > 1 -%}
+                  {% url 'private_thread' thread=thread.pk, slug=thread.slug, page=pagination['page'] %}
+                  {%- else -%}
+                  {% url 'private_thread' thread=thread.pk, slug=thread.slug %}
+                  {%- endif %}#post-{{ post.pk }}" class="post-date">{{ post.date|reltimesince }}</a>
+                </div>
+
+                <a href="{% if pagination['page'] > 1 -%}
+                {% url 'private_thread' thread=thread.pk, slug=thread.slug, page=pagination['page'] %}
+                {%- else -%}
+                {% url 'private_thread' thread=thread.pk, slug=thread.slug %}
+                {%- endif %}#post-{{ post.pk }}" class="post-perma tooltip-left" title="{% trans %}Direct link to this post{% endtrans %}">#{{ pagination['start'] + loop.index }}</a>
+
+                {% if not post.is_read %}
+                <div class="post-extra">
+                  <span class="label label-warning">
+                    {% trans %}New{% endtrans %}
+                  </span>
+                </div>
+                {% endif %}
+
+              </div>
+              <div class="post-message">
+                {% trans %}This reply was posted by user that is on your ignored list.{% endtrans %}
+              </div>
+            </dv>
+          </div>
+          {% else %}
+          <div class="post-body">
+            {% if post.user_id %}
+            <a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}"><img src="{{ post.user.get_avatar(100) }}" alt="" class="user-avatar"></a>
+            {% else %}
+            <img src="{{ macros.avatar_guest(100) }}" alt="" class="user-avatar"></a>
+            {% endif %}
+            <div class="post-arrow"></div>
+            <div class="post-content">
+              <div class="post-header">
+                {% if post.user_id %}
+                <a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}" class="post-author">{{ post.user.username }}</a>{% if post.user.get_title() %} {{ user_label(post.user) }}{% endif %}
+                {% else %}
+                <span class="post-author">{{ post.user_name }}</span> <span class="label post-author-label post-label-guest">{% trans %}Unregistered{% endtrans %}</span>
+                {% endif %}
+                <span class="separator">&ndash;</span>
+                <a href="{% if pagination['page'] > 1 -%}
+                {% url 'private_thread' thread=thread.pk, slug=thread.slug, page=pagination['page'] %}
+                {%- else -%}
+                {% url 'private_thread' thread=thread.pk, slug=thread.slug %}
+                {%- endif %}#post-{{ post.pk }}" class="post-date">{{ post.date|reltimesince }}</a>
+                {% if post.edits %}
+                <span class="separator">&ndash;</span>
+                {% if acl.threads.can_see_changelog(user, forum, post) %}
+                <a href="{% url 'private_thread_changelog' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="post-changelog tooltip-bottom" title="{% trans %}Show changelog{% endtrans %}">{% trans count=post.edits %}One edit{% pluralize %}{{ count }} edits{% endtrans %}</a>
+                {% else %}
+                <span class="post-changelog">{% trans count=post.edits %}One edit{% pluralize %}{{ count }} edits{% endtrans %}</span>
+                {% endif %}
+                {% endif %}
+
+                {% if posts_form %}
+                <label class="checkbox post-checkbox"><input form="posts_form" name="{{ posts_form['list_items']['html_name'] }}" type="checkbox" class="checkbox-member" value="{{ post.pk }}"{% if posts_form['list_items']['has_value'] and ('' ~ post.pk) in posts_form['list_items']['value'] %} checked="checked"{% endif %}></label>
+                {% endif %}
+
+                <a href="{% if pagination['page'] > 1 -%}
+                {% url 'private_thread' thread=thread.pk, slug=thread.slug, page=pagination['page'] %}
+                {%- else -%}
+                {% url 'private_thread' thread=thread.pk, slug=thread.slug %}
+                {%- endif %}#post-{{ post.pk }}" class="post-perma tooltip-left" title="{% trans %}Direct link to this post{% endtrans %}">#{{ pagination['start'] + loop.index }}</a>
+
+                <div class="post-extra">
+                  {% if post.protected and acl.threads.can_protect(forum) %}
+                  <span class="label label-info">
+                    {% trans %}Protected{% endtrans %}
+                  </span>
+                  {% endif %}
+
+                  {% if post.deleted %}
+                  <span class="label label-inverse">
+                    {% trans %}Deleted{% endtrans %}
+                  </span>
+                  {% endif %}
+
+                  {% if post.moderated %}
+                  <span class="label label-purple">
+                    {% trans %}Unreviewed{% endtrans %}
+                  </span>
+                  {% endif %}
+
+                  {% if post.reported %}
+                  <span class="label label-important">
+                    {% trans %}Reported{% endtrans %}
+                  </span>
+                  {% endif %}
+
+                  {% if not post.is_read %}
+                  <span class="label label-warning">
+                    {% trans %}New{% endtrans %}
+                  </span>
+                  {% endif %}
+                </div>
+              </div>
+              <div class="post-message">
+                <div class="markdown js-extra">
+                  {{ post.post_preparsed|markdown_final|safe }}
+                </div>
+                {% if post.user.signature %}
+                <div class="post-signature">
+                  <div class="markdown">
+                    {{ post.user.signature_preparsed|markdown_final|safe }}
+                  </div>
+                </div>
+                {% endif %}
+              </div>
+              <div class="post-footer">{% filter trim %}
+                <div class="post-actions">              
+                  {% if acl.users.can_see_users_trails() -%}
+                  <a href="{% url 'private_post_info' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="post-trail">{% trans %}Info{% endtrans %}</a>
+                  {% endif %}
+                  {% if acl.threads.can_edit_thread(user, forum, thread, post) and thread.start_post_id == post.pk %}
+                  <a href="{% url 'private_thread_edit' thread=thread.pk, slug=thread.slug %}" class="post-edit">{% trans %}Edit{% endtrans %}</a>
+                  {% elif acl.threads.can_edit_reply(user, forum, thread, post) %}
+                  <a href="{% url 'private_post_edit' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="post-edit">{% trans %}Edit{% endtrans %}</a>
+                  {%- endif %}
+                  {% if acl.threads.can_reply(forum, thread) %}<a href="{% url 'private_thread_reply' thread=thread.pk, slug=thread.slug, quote=post.pk %}" class="post-reply">{% trans %}Reply{% endtrans %}</a>{% endif %}
+                </div>
+                {% if post.pk == thread.start_post_id %}
+                <div class="post-actions">
+                  {% if acl.threads.can_delete_thread(user, forum, thread, post) == 2 %}
+                  <form action="{% url 'private_thread_delete' thread=thread.pk, slug=thread.slug %}" class="form-inline prompt-delete-thread" method="post">
+                    <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+                    <span>{% trans %}Delete thread:{% endtrans %}</span>
+                    <button type="submit" class="btn btn-link tooltip-top" title="{% trans %}Delete this thread for good{% endtrans %}">{% trans %}Hard{% endtrans %}</button>
+                  </form>
+                  {% endif %}
+                  {% if not post.deleted and acl.threads.can_delete_thread(user, forum, thread, post) %}
+                  <form action="{% url 'private_thread_hide' thread=thread.pk, slug=thread.slug %}" class="form-inline prompt-delete-thread" method="post">
+                    <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+                    {% if acl.threads.can_delete_thread(user, forum, thread, post) != 2 %}
+                    <span>{% trans %}Delete thread:{% endtrans %}</span>
+                    {% endif %}
+                    <button type="submit" class="btn btn-link tooltip-top" title="{% trans %}Hide this thread from other users{% endtrans %}">{% trans %}Soft{% endtrans %}</button>
+                  </form>
+                  {% endif %}
+                </div>
+                {% elif post.pk != thread.start_post_id and acl.threads.can_delete_post(user, forum, thread, post) %}
+                <div class="post-actions">
+                  {% if acl.threads.can_delete_post(user, forum, thread, post) == 2 -%}
+                  <form action="{% url 'private_post_delete' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="form-inline prompt-delete-post" method="post">
+                    <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+                    <span>{% trans %}Delete reply:{% endtrans %}</span>
+                    <button type="submit" class="btn btn-link tooltip-top" title="{% trans %}Delete this reply for good{% endtrans %}">{% trans %}Hard{% endtrans %}</button>
+                  </form>
+                  {% endif %}
+                  {% if not post.deleted and acl.threads.can_delete_post(user, forum, thread, post) %}
+                  <form action="{% url 'private_post_hide' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="form-inline prompt-delete-post" method="post">
+                    <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+                    {% if acl.threads.can_delete_post(user, forum, thread, post) != 2 %}
+                    <span>{% trans %}Delete reply:{% endtrans %}</span>
+                    {% endif %}
+                    <button type="submit" class="btn btn-link tooltip-top" title="{% trans %}Hide this reply from other users{% endtrans %}">{% trans %}Soft{% endtrans %}</button>
+                  </form>
+                  {% endif %}
+                </div>
+                {% endif %}
+              {% endfilter %}</div>
+            </div>
+          </div>
+          {% endif %}
+        </div>
+
+        {% if post.checkpoint_set.all() %}
+        <div class="post-checkpoints">
+          {% for checkpoint in post.checkpoint_set.all() %}
+          <div class="post-checkpoint">
+            <hr>
+            <span>
+              {%- if checkpoint.action == 'limit' -%}
+              <i class="icon-lock"></i> {% trans  %}This thread has reached its post limit and has been closed.{% endtrans %}
+              {%- elif checkpoint.action == 'accepted' -%}
+              <i class="icon-ok"></i> {% trans user=checkpoint_user(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} accepted this thread {{ date }}{% endtrans %}
+              {%- elif checkpoint.action == 'closed' -%}
+              <i class="icon-lock"></i> {% trans user=checkpoint_user(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} closed this thread {{ date }}{% endtrans %}
+              {%- elif checkpoint.action == 'opened' -%}
+              <i class="icon-lock"></i> {% trans user=checkpoint_user(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} opened this thread {{ date }}{% endtrans %}
+              {%- elif checkpoint.action == 'deleted' -%}
+              <i class="icon-trash"></i> {% trans user=checkpoint_user(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} deleted this thread {{ date }}{% endtrans %}
+              {%- elif checkpoint.action == 'undeleted' -%}
+              <i class="icon-trash"></i> {% trans user=checkpoint_user(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} restored this thread {{ date }}{% endtrans %}
+              {%- elif checkpoint.action == 'invited' -%}
+              <i class="icon-plus-sign"></i> {% trans user=checkpoint_user(checkpoint), invited=checkpoint_target(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} added {{ invited }} to thread {{ date }}{% endtrans %}
+              {%- elif checkpoint.action == 'removed' -%}
+              <i class="icon-remove-sign"></i> {% trans user=checkpoint_user(checkpoint), removed=checkpoint_target(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} removed {{ removed }} from thread {{ date }}{% endtrans %}
+              {%- elif checkpoint.action == 'left' -%}
+              <i class="icon-remove-sign"></i> {% trans user=checkpoint_user(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} left thread {{ date }}{% endtrans %}
+              {%- endif -%}
+            </span>
+          </div>
+          {% endfor %}
+        </div>
+        {% endif %}
+        {% endfor %}
+      </div>
+
+      {% if thread_form or posts_form %}
+      <div class="thread-moderation">
+        {% if thread_form%}
+        <form id="thread_form" class="form-inline pull-left" action="{% url 'private_thread' slug=thread.slug, thread=thread.id, page=pagination['page'] %}" method="POST">
+          <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+          <input type="hidden" name="origin" value="thread_form">
+          {{ form_theme.input_select(thread_form['thread_action'],width=3) }}
+          <button type="submit" class="btn btn-danger">{% trans %}Go{% endtrans %}</button>
+        </form>
+        {% endif %}
+        {% if posts_form%}
+        <form id="posts_form" class="form-inline pull-right" action="{% url 'private_thread' slug=thread.slug, thread=thread.id, page=pagination['page'] %}" method="POST">
+          <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+          <input type="hidden" name="origin" value="posts_form">
+          {{ form_theme.input_select(posts_form['list_action'],width=3) }}
+          <button type="submit" class="btn btn-danger">{% trans %}Go{% endtrans %}</button>
+        </form>
+        {% endif %}
+      </div>
+      {% endif %}
+
+      <div class="thread-buttons">
+        {{ pager(false) }}
+        {% if acl.threads.can_reply(forum, thread) and participants|length > 1 %}
+        <a href="{% url 'private_thread_reply' thread=thread.pk, slug=thread.slug %}" class="btn btn-inverse pull-right"><i class="icon-pencil"></i> {% trans %}Reply{% endtrans %}</a>
+        {% else %}
+        <p class="lead thread-signin-message">{% trans %}This thread has no participants.{% endtrans %}</p>
+        {% endif %}
+      </div>
+
+      {% if acl.threads.can_reply(forum, thread) and participants|length > 1 %}
+      <div class="thread-quick-reply">
+        <form action="{% url 'private_thread_reply' thread=thread.pk, slug=thread.slug %}" method="post">
+          <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+          <input type="hidden" name="quick_reply" value="1">
+          <img src="{{ user.get_avatar(100) }}" alt="{% trans %}Your Avatar{% endtrans %}" class="user-avatar">
+          {{ editor.editor(quick_reply.post, _('Post Reply'), extra=editor_extra()) }}
+        </form>
+      </div>
+      {% endif %}
+
+    </div>
+    <div class="span3">
+      <div class="thread-participants">
+        <h3>{% trans count=participants|length -%}
+          One participant
+          {%- pluralize -%}
+          {{ count }} participants
+          {%- endtrans %}</h3>
+        <ul class="unstyled">{% for participant in participants %}
+          <li>
+            <img src="{{ participant.get_avatar(24) }}" alt="" class="avatar-small">
+            <a href="{% url 'user' username=participant.username_slug, user=participant.pk %}">{{ participant.username }}</a>
+            {% if user.pk == thread.start_poster_id or acl.private_threads.is_mod() %}
+            <form class="form-inline {% if user.pk == thread.start_poster_id %}leave-form{% else %}kick-form{% endif %} tooltip-left" action="{% url 'private_thread_remove_user' thread=thread.pk, slug=thread.slug %}" method="post" title="{% if participant.pk == user.pk %}{% trans %}Leave this thread{% endtrans %}{% else %}{% trans %}Remove from this thread{% endtrans %}{% endif %}">
+              <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+              <input type="hidden" name="retreat" value="{{ request_path }}">
+              <input type="hidden" name="user" value="{{ participant.pk }}">
+              <button type="submit" class="btn btn-{% if participant.pk == user.pk %}danger{% else %}inverse{% endif %} btn-small"><i class="icon-remove"></i></button>
+            </form>
+            {% endif %}
+          </li>
+          {% endfor %}
+        </ul>
+
+        {% if participants|length < 2%}
+        <p class="no-participants">{% trans %}This thread has too few participants. Invite other users to open it for new replies.{% endtrans %}</p>
+        {% endif %}
+
+        <h4>{% trans %}Invite User{% endtrans %}</h4>
+        <div class="invite-participant">
+          <form class="form-inline" action="{% url 'private_thread_invite_user' thread=thread.pk, slug=thread.slug %}" method="post">
+            <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+            <input type="hidden" name="retreat" value="{{ request_path }}">
+            {{ form_theme.input_text(invite_form.fields.username, width="2", attrs={'placeholder':_("User to invite...")}) }}
+            <button class="btn" type="submit"><i class="icon-plus"></i></button>
+          </form>
+        </div>
+      </div>
+    </div>
+  </div>
+
+</div>
+{% endblock %}
+
+{% block stylesheets %}{{ super() }}
+<link href="{{ STATIC_URL }}cranefly/highlight/styles/monokai.css" rel="stylesheet">
+{% endblock %}
+
+{% block javascripts -%}{{ super() }}
+  <script src="{{ STATIC_URL }}cranefly/highlight/highlight.pack.js"></script>
+  <script type="text/javascript">
+    hljs.tabReplace = '    ';
+    hljs.initHighlightingOnLoad();
+    EnhancePostsMD();
+    $(function () {
+      $('.leave-form').submit(function() {
+        var decision = confirm("{% if participants|length == 1 -%}
+          {% trans %}Are you sure you want to leave this thread? It will be deleted after you leave!{% endtrans %}
+          {%- else -%}
+          {% trans %}Are you sure you want to leave this thread?{% endtrans %}
+          {%- endif %}");
+        return decision;
+      });
+      $('.kick-form').submit(function() {
+        var decision = confirm("{% trans %}Are you sure you want to remove this member from this thread?{% endtrans %}");
+        return decision;
+      });
+      $('#thread_form').submit(function() {
+        if ($('#id_thread_action').val() == 'hard') {
+          var decision = confirm("{% trans %}Are you sure you want to delete this thread? This action is not reversible!{% endtrans %}");
+          return decision;
+        }
+        return true;
+      });
+      $('#posts_form').submit(function() {
+        if ($('.post-checkbox[]:checked').length == 0) {
+          alert("{% trans %}You have to select at least one post.{% endtrans %}");
+          return false;
+        }
+        if ($('#id_list_action').val() == 'merge') {
+          if ($('.post-checkbox[]:checked').length < 2) {
+              alert("{% trans %}You have to select at least two posts you want to merge.{% endtrans %}");
+              return false;
+          }
+          var decision = confirm("{% trans %}Are you sure you want to merge selected posts? This action is not reversible!{% endtrans %}");
+          return decision;
+        }
+        if ($('#id_list_action').val() == 'hard') {
+          var decision = confirm("{% trans %}Are you sure you want to delete selected posts? This action is not reversible!{% endtrans %}");
+          return decision;
+        }
+        return true;
+      });
+      $('.prompt-delete-thread').submit(function() {
+          var decision = confirm("{% trans %}Are you sure you want to delete this thread?{% endtrans %}");
+          return decision;
+      });
+      $('.prompt-delete-post').submit(function() {
+          var decision = confirm("{% trans %}Are you sure you want to delete this post?{% endtrans %}");
+          return decision;
+      });
+    });
+  </script>
+  {% if acl.threads.can_reply(forum, thread) %}
+  {{ editor.js() }}
+  {% endif %}
+{%- endblock %}
+
+
+{% macro user_label(user) -%}
+<{% if user.rank and user.rank.as_tab %}a href="{% url 'users' slug=user.rank.slug %}"{% else %}span{% endif %} class="label post-author-label{% if user.rank and user.rank.style %} post-label-{{ user.rank.style }}{% endif %}">{{ user.get_title() }}</{% if user.rank and user.rank.as_tab%}a{% else %}span{% endif %}>
+{%- endmacro %}
+
+
+{% macro pager(extra=true) %}
+<div class="pagination pull-left">
+  <ul>
+    {% if pagination['total'] > 0 %}
+    <li class="count">{{ macros.pager_label(pagination) }}</li>
+    {%- if pagination['prev'] > 1 %}<li><a href="{% url 'private_thread' slug=thread.slug, thread=thread.id %}" class="tooltip-top" title="{% trans %}Go to first page{% endtrans %}"><i class="icon-chevron-left"></i> {% trans %}First{% endtrans %}</a></li>{% endif -%}
+    {%- if pagination['prev'] > 0 %}<li><a href="{%- if pagination['prev'] > 1 %}{% url 'private_thread' slug=thread.slug, thread=thread.id, page=pagination['prev'] %}{% else %}{% url 'private_thread' slug=thread.slug, thread=thread.id %}{% endif %}" class="tooltip-top" title="{% trans %}Older Posts{% endtrans %}"><i class="icon-chevron-left"></i></a></li>{% endif -%}
+    {%- if pagination['next'] > 0 %}<li><a href="{% url 'private_thread' slug=thread.slug, thread=thread.id, page=pagination['next'] %}" class="tooltip-top" title="{% trans %}Newest Posts{% endtrans %}"><i class="icon-chevron-right"></i></a></li>{% endif -%}
+    {%- if pagination['next'] > 0 and pagination['next'] < pagination['total'] %}<li><a href="{% url 'private_thread' slug=thread.slug, thread=thread.id, page=pagination['total'] %}" class="tooltip-top" title="{% trans %}Go to last page{% endtrans %}">{% trans %}Last{% endtrans %} <i class="icon-chevron-right"></i></a></li>{% endif -%}
+    {% endif %}
+    {% if extra %}
+    {% if not is_read %}<li><a href="{% url 'private_thread_new' slug=thread.slug, thread=thread.id %}" class="tooltip-top" title="{% trans %}Go to first unread{% endtrans %}"><i class="icon-star"></i> {% trans %}First Unread{% endtrans %}</a></li>{% endif %}
+    {% endif %}
+  </ul>
+</div>
+{% endmacro %}
+
+
+{% macro checkpoint_user(checkpoint) -%}
+{%- if checkpoint.user_id -%}
+<a href="{{ 'user'|url(user=checkpoint.user_id, username=checkpoint.user_slug) }}">{{ checkpoint.user_name }}</a>
+{%- else -%}
+<strong>{{ checkpoint.user_name }}</strong>
+{%- endif -%}
+{%- endmacro %}
+
+
+{% macro checkpoint_target(checkpoint) -%}
+{%- if checkpoint.target_user_id -%}
+<a href="{{ 'user'|url(user=checkpoint.target_user_id, username=checkpoint.target_user_slug) }}">{{ checkpoint.target_user_name }}</a>
+{%- else -%}
+<strong>{{ checkpoint.target_user_name }}</strong>
+{%- endif -%}
+{%- endmacro %}
+
+
+{% macro edit_user(post) -%}
+{%- if post.edit_user_id -%}
+<a href="{{ 'user'|url(user=post.edit_user_id, username=post.edit_user_slug) }}">{{ post.edit_user_name }}</a>
+{%- else -%}
+<strong>{{ post.edit_user_name }}</strong>
+{%- endif -%}
+{%- endmacro %}
+
+
+{% macro editor_extra() %}
+  <button id="editor-preview" name="preview" type="submit" class="btn pull-right">{% trans %}Full Editor{% endtrans %}</button>
+{% endmacro %}

+ 5 - 5
templates/cranefly/profiles/list.html

@@ -17,7 +17,7 @@
     <h1>{% trans %}Users List{% endtrans %} <small>{% trans %}Browse notable user groups or find specific user{% endtrans %}</small></h1>
     <ul class="nav nav-tabs header-tabs">
       {% for rank in ranks %}
-      <li{% if active_rank.id == rank.id %} class="active"{% endif %}><a href="{% if loop.first %}{% url 'users' %}{% else %}{% url 'users' rank_slug=rank.name_slug %}{% endif %}">{{ _(rank.name) }}</a></li>
+      <li{% if active_rank.id == rank.id %} class="active"{% endif %}><a href="{% if loop.first %}{% url 'users' %}{% else %}{% url 'users' slug=rank.slug %}{% endif %}">{{ _(rank.name) }}</a></li>
       {% endfor %}
       {% if acl.users.can_search_users() and not user.is_crawler() %}
       <li class="pull-right">
@@ -35,7 +35,7 @@
 <div class="container container-primary">
   <div class="profiles-list">
 
-    <h2>{% if in_search %}{% trans %}Search Users{% endtrans %}{% elif active_rank %}{{ _(active_rank.name) }}{% endif %}</h2>
+    {% if in_search %}<h2>{% trans %}Search Users{% endtrans %}</h2>{% endif %}
 
     {% if message %}
     <div class="messages-list">
@@ -46,7 +46,7 @@
     {% if in_search and not message and users|length > 0 %}
     <p class="lead">{% trans %}We couldn't find a member with name you entered, so we present you with some other members with names similiar to one you searched for in hopes that one of them will turn out to be member you are looking for.{% endtrans %}</p>
     {% elif active_rank and active_rank.description %}
-    <div class="markdown">{{ active_rank.description|markdown|safe }}</div>
+    <div class="markdown lead">{{ active_rank.description }}</div>
     {% endif %}
 
     {% if users|length > 0 %}
@@ -102,8 +102,8 @@
 <div class="pagination">
   <ul>
     <li class="count">{{ macros.pager_label(pagination) }}</li>
-    {%- if pagination['prev'] > 0 %}<li><a href="{%- if pagination['prev'] > 1 %}{% url 'users' rank_slug=active_rank.rank_slug, page=pagination['prev'] %}{% else %}{% url 'users' rank_slug=active_rank.rank_slug %}{% endif %}" class="tooltip-top" title="{% trans %}Previous Page{% endtrans %}"><i class="icon-chevron-left"></i></a></li>{% endif -%}
-    {%- if pagination['next'] > 0 %}<li><a href="{% url 'users' rank_slug=active_rank.rank_slug, page=pagination['next'] %}" class="tooltip-top" title="{% trans %}Next Page{% endtrans %}"><i class="icon-chevron-right"></i></a></li>{% endif -%}
+    {%- if pagination['prev'] > 0 %}<li><a href="{%- if pagination['prev'] > 1 %}{% url 'users' slug=active_rank.slug, page=pagination['prev'] %}{% else %}{% url 'users' slug=active_rank.slug %}{% endif %}" class="tooltip-top" title="{% trans %}Previous Page{% endtrans %}"><i class="icon-chevron-left"></i></a></li>{% endif -%}
+    {%- if pagination['next'] > 0 %}<li><a href="{% url 'users' slug=active_rank.slug, page=pagination['next'] %}" class="tooltip-top" title="{% trans %}Next Page{% endtrans %}"><i class="icon-chevron-right"></i></a></li>{% endif -%}
   </ul>
 </div>
 {% endif %}

+ 2 - 2
templates/cranefly/profiles/posts.html

@@ -25,9 +25,9 @@
     <div class="media-body">
       <a href="{% url 'thread_find' thread=item.thread.pk, slug=item.thread.slug, post=item.pk %}" class="post-preview">{{ item.post_preparsed|markdown_short(300) }}</a>
       <div class="media-footer">{% if item.thread.start_post_id == item.pk -%}
-      {% trans thread=thread(item), forum=forum(item.forum), date=item.date|reldate|low %}Thread {{ thread }} posted in {{ forum }} {{ date }}{% endtrans %}
+      {% trans thread=thread(item), forum=forum(item.forum), date=item.date|reltimesince|low %}Thread {{ thread }} posted in {{ forum }} {{ date }}{% endtrans %}
       {%- else -%}
-      {% trans thread=thread(item), forum=forum(item.forum), date=item.date|reldate|low %}Reply to {{ thread }} posted in {{ forum }} {{ date }}{% endtrans %}
+      {% trans thread=thread(item), forum=forum(item.forum), date=item.date|reltimesince|low %}Reply to {{ thread }} posted in {{ forum }} {{ date }}{% endtrans %}
       {%- endif %}</div>
     </div>
   </div>

+ 9 - 0
templates/cranefly/profiles/profile.html

@@ -9,7 +9,11 @@
     <div class="container">
       {{ messages_list(messages) }}
       <div class="header-row">
+        {% if profile.id == user.id and not user.avatar_ban %}
+        <a href="{% url 'usercp_avatar' %}"><img src="{{ profile.get_avatar() }}" class="header-avatar tooltip-right" title="{% trans %}Click to jump to your Avatar Settings{% endtrans %}"></a>
+        {% else %}
         <img src="{{ profile.get_avatar() }}" class="header-avatar">
+        {% endif %}
         <div class="header-side">
           <h1>{{ profile.username }} <small>{% if profile.title or profile.rank.title -%}
             <strong>{% if profile.title %}{{ _(profile.title) }}{% elif profile.rank.title %}{{ _(profile.rank.title) }}{% endif %}</strong>; {% endif %}
@@ -57,6 +61,11 @@
                 </button>
               </form>
             </li>
+            {% if acl.private_threads.can_start() %}
+            <li class="pull-right">
+              <a href="{% url 'private_thread_start_with' username=profile.username_slug, user=profile.pk %}" class="btn"><i class="icon-envelope"></i> {% trans %}Message{% endtrans %}</a>
+            </li>
+            {% endif %}
             {% endif %}
           </ul>
         </div>

+ 1 - 1
templates/cranefly/profiles/threads.html

@@ -24,7 +24,7 @@
     </a>
     <div class="media-body">
       <a href="{% url 'thread' thread=item.pk, slug=item.slug %}" class="post-preview">{{ item.start_post.post_preparsed|markdown_short(300) }}</a>
-      <div class="media-footer">{% trans thread=thread(item), forum=forum(item.forum), date=item.start|reldate|low %}Thread {{ thread }} posted in {{ forum }} {{ date }}{% endtrans %}</div>
+      <div class="media-footer">{% trans thread=thread(item), forum=forum(item.forum), date=item.start|reltimesince|low %}Thread {{ thread }} posted in {{ forum }} {{ date }}{% endtrans %}</div>
     </div>
   </div>
   <hr>

+ 3 - 3
templates/cranefly/threads/changelog.html

@@ -47,13 +47,13 @@
             </span>
           </td>
           <td>
-            <a href="{% url 'changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=edit.pk %}" class="change-no">#{{ loop.revindex }}</a>
+            <a href="{% url 'thread_changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=edit.pk %}" class="change-no">#{{ loop.revindex }}</a>
             {% if edit.reason %}
             <div class="change-reason">
-              <a href="{% url 'changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=edit.pk %}">{{ edit.reason }}</a>
+              <a href="{% url 'thread_changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=edit.pk %}">{{ edit.reason }}</a>
             </div>{% endif %}
             <div class="change-description">
-              <a href="{% url 'changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=edit.pk %}">
+              <a href="{% url 'thread_changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=edit.pk %}">
               {% if edit.change != 0 %}{% if edit.change > 0 -%}
               {% trans chars=edit.change %}Added one character to post.{% pluralize %}Added {{ chars }} characters to post.{% endtrans %}
               {%- elif edit.change < 0 -%}

+ 4 - 4
templates/cranefly/threads/changelog_diff.html

@@ -8,7 +8,7 @@
 <li><a href="{{ parent.type|url(forum=parent.pk, slug=parent.slug) }}">{{ parent.name }}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
 {% endfor %}
 <li><a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}">{{ thread.name }}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
-<li><a href="{% url 'changelog' thread=thread.pk, slug=thread.slug, post=post.pk %}">{% trans post=post.pk %}Post #{{ post }} Changelog{% endtrans %}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
+<li><a href="{% url 'thread_changelog' thread=thread.pk, slug=thread.slug, post=post.pk %}">{% trans post=post.pk %}Post #{{ post }} Changelog{% endtrans %}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
 <li class="active">{% trans date=change.date|reltimesince|low %}Edit from {{ date }}{% endtrans %}
 {%- endblock %}
 
@@ -52,7 +52,7 @@
     <div class="diff-extra">
       {{ pager() }}
       {% if user.is_authenticated() and acl.threads.can_make_revert(forum, thread) %}
-      <form class="form-inline pull-right" action="{% url 'changelog_revert' thread=thread.pk, slug=thread.slug, post=post.pk, change=change.pk %}" method="post">
+      <form class="form-inline pull-right" action="{% url 'thread_changelog_revert' thread=thread.pk, slug=thread.slug, post=post.pk, change=change.pk %}" method="post">
         <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
         <button type="submit" class="btn btn-danger">{% trans %}Revert this edit{% endtrans %}</button></li>
       </form>
@@ -89,8 +89,8 @@
 {% if prev or prev %}
 <div class="pagination pull-left">
   <ul>
-    {% if prev %}<li><a href="{% url 'changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=prev.pk %}"><i class="icon-chevron-left"></i> {{ prev.date|reldate }}</a></li>{% endif %}
-    {% if next %}<li><a href="{% url 'changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=next.pk %}">{{ next.date|reldate }} <i class="icon-chevron-right"></i></a></li>{% endif %}
+    {% if prev %}<li><a href="{% url 'thread_changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=prev.pk %}"><i class="icon-chevron-left"></i> {{ prev.date|reldate }}</a></li>{% endif %}
+    {% if next %}<li><a href="{% url 'thread_changelog_diff' thread=thread.pk, slug=thread.slug, post=post.pk, change=next.pk %}">{{ next.date|reldate }} <i class="icon-chevron-right"></i></a></li>{% endif %}
   </ul>
 </div>
 {% endif %}

+ 21 - 2
templates/cranefly/threads/karmas.html

@@ -93,7 +93,26 @@
 </div>
 {% endblock %}
 
+{% block javascripts %}{{ super() }}
+  <script type="text/javascript">
+    $(function() {
+      {% for vote in (upvotes|list + downvotes|list) %}
+      $('.vote-{{ vote.id }}').popover({
+        'placement': 'top',
+        'trigger': 'hover',
+        'html': true,
+        {% if acl.users.can_see_users_trails() %}
+        'title': '<strong>{{ vote.date|reldate }}</strong>',
+        'content': '{% trans ip=vote.ip %}From {{ ip }}{% endtrans %}'
+        {% else %}
+        'content': '<strong>{{ vote.ip }}</strong>'
+        {% endif %}
+      });
+      {% endfor %}
+    });
+  </script>
+{% endblock %}
+
 {% macro vote_details(vote, icon) %}
-{% if vote.user_id %}<a href="{% url 'user' user=vote.user_id, username=vote.user_slug %}" class="vote-user"><span class="vote-icon"><i class="icon-{{ icon }}"></i></span> {{ vote.user_name }}</a>{% else %}<span class="vote-user"><span class="vote-icon"><i class="icon-{{ icon }}"></i></span> {{ vote.user_name }}</span>{% endif %}
-<p class="vote-date {% if acl.users.can_see_users_trails() %} tooltip-top{% endif %}"{% if acl.users.can_see_users_trails() %} title="{{ vote.agent }}"{% endif %}>{{ vote.date|reldate }}{% if acl.users.can_see_users_trails() %}, {{ vote.ip }}{% endif %}</p>
+{% if vote.user_id %}<a href="{% url 'user' user=vote.user_id, username=vote.user_slug %}" class="vote-user vote-{{ vote.pk }}"><span class="vote-icon"><i class="icon-{{ icon }}"></i></span> {{ vote.user_name }}</a>{% else %}<span class="vote-user vote-{{ vote.pk }}"><span class="vote-icon"><i class="icon-{{ icon }}"></i></span> {{ vote.user_name }}</span>{% endif %}
 {% endmacro %}

+ 43 - 19
templates/cranefly/threads/list.html

@@ -15,7 +15,7 @@
 <div class="page-header header-primary">
   <div class="container">
     {{ messages_list(messages) }}
-    <ul class="breadcrumb">
+    <ul class="breadcrumb" {{ macros.itemprop_bread() }}>
       {{ self.breadcrumb() }}</li>
     </ul>
     <h1>{{ forum.name }}</h1>
@@ -37,19 +37,33 @@
       <tbody>
         {% for subforum in forum.subforums %}
         <tr>
-          <td class="forum-icon"><span class="forum-icon-wrap{% if subforum.type == 'redirect' %} forum-icon-redirect{% elif not subforum.is_read %} forum-icon-new{% endif %}"><i class="icon-{% if subforum.type == 'redirect' %}circle-arrow-right{% else %}comment{% endif %} icon-white"></i></span></td>
-          <td class="forum-main">
-            <h3><a href="{{ subforum.type|url(slug=subforum.slug, forum=subforum.id) }}">{{ subforum.name }}</a></h3>
-            {% if subforum.show_details %}
+          <td class="forum-icon"><span class="forum-icon-wrap{% if forum.type == 'redirect' %} forum-icon-redirect{% elif not forum.is_read %} forum-icon-new{% endif %}"><i class="icon-{% if forum.type == 'redirect' %}circle-arrow-right{% else %}comment{% endif %} icon-white"></i></span></td>
+          <td id="forum-{{ forum.id }}" class="forum-main">
+            <h3 class="forum-title{% if not forum.is_read %} forum-title-new{% endif %}"><a href="{{ forum.type|url(slug=forum.slug, forum=forum.id) }}">{{ forum.name }}</a></h3>
+            {% if forum.show_details and forum.type != 'redirect' %}
             <div class="forum-details">
-              {% if forum.type == 'redirect' %}
-              {{ redirect_stats(forum) }}
-              {% else %}
-              {{ forum_stats(forum) }}
-              {% endif %}
+              {% if acl.forums.can_browse(forum) and (acl.threads.can_read_threads(forum) == 2 or (acl.threads.can_read_threads(forum) == 1 and forum.last_poster_id == user.pk)) %}
+              {% if forum.last_thread_id -%}
+              {% trans %}Latest{% endtrans %}: <a href="{% url 'thread_new' thread=forum.last_thread_id, slug=forum.last_thread_slug %}" class="tooltip-top" title="{{ forum.last_thread_name }}">{{ forum.last_thread_name|short_string(28) }}</a>
+              {%- else -%}
+              <em>{% trans %}This forum is empty{% endtrans %}</em>
+              {%- endif %}
+              {%- else -%}
+              <em>{% trans %}This forum is protected{% endtrans %}</em>
+              {%- endif %}
             </div>
             {% endif %}
-            {% if subforum.description %}<p class="forum-description">{{ subforum.description }}</p>{% endif %}
+            <div class="hide forum-meta">
+              {% if forum.description %}<p class="forum-description">{{ forum.description }}</p>{% endif %}
+              <div class="forum-stats">
+                {% if forum.type != 'redirect' %}
+                <span>{% trans %}Posts{% endtrans %}: <strong>{{ forum.posts|intcomma }}</strong></span>
+                {% trans %}Threads{% endtrans %}: <strong>{{ forum.threads|intcomma }}</strong>
+                {% else %}
+                {% trans %}Clicks{% endtrans %}: <strong>{{ forum.redirects|intcomma }}</strong>
+                {% endif %}
+              </div>
+            </div>
           </td>
         </tr>
         {% endfor %}
@@ -67,7 +81,7 @@
   <div class="forum-threads-extra extra-top">
     {{ pager() }}
     {% if user.is_authenticated() and acl.threads.can_start_threads(forum) %}
-    <a href="{% url 'thread_new' forum=forum.pk, slug=forum.slug %}" class="btn btn-inverse pull-right"><i class="icon-plus"></i> {% trans %}New Thread{% endtrans %}</a>
+    <a href="{% url 'thread_start' forum=forum.pk, slug=forum.slug %}" class="btn btn-inverse pull-right"><i class="icon-plus"></i> {% trans %}New Thread{% endtrans %}</a>
     {% endif %}
   </div>
 
@@ -127,7 +141,7 @@
           </td>
           <td>
             <div class="thread-last-reply">
-              {{ replies(thread.replies) }} - {% trans user=thread_reply(thread), last=thread.last|reldate|low %}last by {{ user }} {{ last }}{% endtrans %}
+              {{ replies(thread.replies) }} - {% trans user=thread_reply(thread), last=thread.last|reltimesince|low %}last by {{ user }} {{ last }}{% endtrans %}
             </div>
           </td>
           {% if user.is_authenticated() and list_form %}
@@ -157,7 +171,7 @@
   <div class="forum-threads-extra">
     {{ pager() }}
     {% if user.is_authenticated() and acl.threads.can_start_threads(forum) %}
-    <a href="{% url 'thread_new' forum=forum.pk, slug=forum.slug %}" class="btn btn-inverse pull-right"><i class="icon-plus"></i> {% trans %}New Thread{% endtrans %}</a>
+    <a href="{% url 'thread_start' forum=forum.pk, slug=forum.slug %}" class="btn btn-inverse pull-right"><i class="icon-plus"></i> {% trans %}New Thread{% endtrans %}</a>
     {% elif not user.is_authenticated() and not user.is_crawler() %}
     <p class="lead threads-signin-message"><a href="{% url 'sign_in' %}">{% trans %}Sign in or register to start threads.{% endtrans %}</a></p>
     {% endif %}
@@ -228,11 +242,21 @@
 {% endif %}
 {% endmacro %}
 
-{% block javascripts -%}
-{{ super() }}
-{%- if user.is_authenticated() and list_form %}
+{% block javascripts -%}{{ super() }}
   <script type="text/javascript">
     $(function () {
+      function populateForumTooltip(target) {
+        return $('#forum-' + target + ' .forum-meta').html();
+      };
+      {% for category in forums_list %}{% for forum in category.subforums %}
+        $('#forum-{{ forum.id }} .forum-title').tooltip({
+          template: '<div class="tooltip forum-meta-tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',
+          placement: 'right',
+          html: true,
+          title: populateForumTooltip({{ forum.id }})
+        });
+      {% endfor %}{% endfor %}
+      {%- if user.is_authenticated() and list_form %}
       $('#threads_form').submit(function() {
         if ($('.check-cell[]:checked').length == 0) {
           alert("{% trans %}You have to select at least one thread.{% endtrans %}");
@@ -243,7 +267,7 @@
           return decision;
         }
         return true;
-      });
+      });{% endif %}
     });
-  </script>{% endif %}
+  </script>
 {%- endblock %}

+ 0 - 27
templates/cranefly/threads/merge.html

@@ -57,31 +57,4 @@
     </div>
   </div>
 </div>
-{% endblock %}
-
-{% block content %}
-<div class="page-header">
-  <ul class="breadcrumb">
-    {{ self.breadcrumb() }}</li>
-  </ul>
-  <h1>{% trans %}Merge Threads{% endtrans %} <small>{{ forum.name }}</small></h1>
-</div>
-<div class="row">
-  <div class="span8 offset2">
-    {% if message %}{{ macros.draw_message(message) }}{% endif %}
-    <form action="{% url 'forum' forum=forum.pk, slug=forum.slug %}" method="post">
-      <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
-      <input type="hidden" name="origin" value="merge_form">
-      <input type="hidden" name="list_action" value="merge">
-      {% for thread in threads -%}
-      <input type="hidden" name="list_items" value="{{ thread.pk }}">
-      {% endfor %}
-      {{ form_theme.form_widget(form, width=8) }}
-      <div class="form-actions">
-        <button name="save" type="submit" class="btn btn-primary">{% trans %}Merge Threads{% endtrans %}</button>
-        <a href="{% url 'forum' forum=forum.pk, slug=forum.slug %}" class="btn">{% trans %}Cancel{% endtrans %}</a>
-      </div>
-    </form>
-  </div>
-</div>
 {% endblock %}

+ 32 - 46
templates/cranefly/threads/posting.html

@@ -13,6 +13,7 @@
 {% for parent in parents %}
 <li><a href="{{ parent.type|url(forum=parent.pk, slug=parent.slug) }}">{{ parent.name }}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
 {% endfor %}
+<li><a href="{{ forum.type|url(forum=forum.pk, slug=forum.slug) }}">{{ forum.name }}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>
 {% if thread %}<li><a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}">{{ thread.name }}</a> <span class="divider"><i class="icon-chevron-right"></i></span></li>{% endif %}
 <li class="active">{{ get_title() }}
 {%- endblock %}
@@ -64,9 +65,25 @@
             <h4>Message Body</h4>
             {% endif %}
             {{ editor.editor(form.fields.post, get_button(), rows=8, extra=get_extra()) }}
-            {% if 'edit_reason' in form.fields %}
+            {% if intersect(form.fields, ('edit_reason', 'thread_weight', 'close_thread')) %}
             <hr>
+            {% if 'edit_reason' in form.fields %}
             {{ form_theme.row_widget(form.fields.edit_reason, width=8) }}
+            {% endif %}
+
+            {% if intersect(form.fields, ('thread_weight', 'close_thread')) %}
+            <div class="control-group">
+              <label class="control-label">{% trans %}Thread Status{% endtrans %}:</label>
+              <div class="controls">
+                {% if 'thread_weight' in form.fields %}
+                {{ form_theme.input_radio_select(form.fields.thread_weight, width=8) }}
+                {% endif %}
+                {% if 'close_thread' in form.fields %}
+                {{ form_theme.input_checkbox(form.fields.close_thread, width=8) }}
+                {% endif %}
+              </div>
+            </div>
+            {% endif %}
 
             <div class="form-actions">
               <button type="submit" class="btn btn-primary">{{ get_button() }}</button>
@@ -82,35 +99,6 @@
 </div>
 {% endblock %}
 
-{% block content %}
-<div class="page-header">
-  <ul class="breadcrumb">
-    {{ self.breadcrumb() }}</li>
-  </ul>
-  <h1>{{ get_title() }} <small>{% if thread %}{{ thread.name }}{% else %}{{ forum.name }}{% endif %}</small></h1>
-  {% if thread %}
-  <ul class="unstyled thread-info">
-    {{ get_info() }}
-  </ul>
-  {%- endif %}
-</div>
-{% if message %}{{ macros.draw_message(message, 'alert-form') }}{% endif %}
-{% if preview %}
-<div class="well" style="margin: 0px; margin-bottom: 32px; padding: 12px;">
-  <div class="markdown">
-    {{ preview|markdown_final|safe }}
-  </div>
-</div>
-<hr>
-{% endif %}
-<form action="{{ get_action() }}" method="post">
-  <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
-  {% if 'thread_name' in form.fields %}{{ form_theme.row_widget(form.fields.thread_name) }}{% endif %}
-  {% if 'edit_reason' in form.fields %}{{ form_theme.row_widget(form.fields.edit_reason) }}{% endif %}
-  {{ editor.editor(form.fields.post, get_button(), rows=8, extra=get_extra()) }}
-</form>
-{% endblock %}
-
 {% block stylesheets %}{{ super() }}
 <link href="{{ STATIC_URL }}cranefly/highlight/styles/monokai.css" rel="stylesheet">
 {% endblock %}
@@ -127,37 +115,37 @@
 
 
 {% macro get_action() -%}
-{% if mode == 'new_thread' -%}
-{% url 'thread_new' forum=forum.pk, slug=forum.slug %}
-{%- elif mode == 'edit_thread' -%}
+{% if action == 'new_thread' -%}
+{% url 'thread_start' forum=forum.pk, slug=forum.slug %}
+{%- elif action == 'edit_thread' -%}
 {% url 'thread_edit' thread=thread.pk, slug=thread.slug %}
-{%- elif mode in ['new_post', 'new_post_quick'] -%}
+{%- elif action in 'new_reply' -%}
 {%- if quote -%}
 {% url 'thread_reply' thread=thread.pk, slug=thread.slug, quote=quote.pk %}
 {%- else -%}
 {% url 'thread_reply' thread=thread.pk, slug=thread.slug %}
 {%- endif -%}
-{%- elif mode == 'edit_post' -%}
+{%- elif action == 'edit_reply' -%}
 {% url 'post_edit' thread=thread.pk, slug=thread.slug, post=post.pk %}
 {%- endif %}
 {%- endmacro %}
 
 
 {% macro get_title() -%}
-{% if mode == 'new_thread' -%}
+{% if action == 'new_thread' -%}
 {% trans %}Post New Thread{% endtrans %}
-{%- elif mode == 'edit_thread' -%}
+{%- elif action == 'edit_thread' -%}
 {% trans %}Edit Thread{% endtrans %}
-{%- elif mode in ['new_post', 'new_post_quick'] -%}
+{%- elif action == 'new_reply' -%}
 {% trans %}Post New Reply{% endtrans %}
-{%- elif mode == 'edit_post' -%}
+{%- elif action == 'edit_reply' -%}
 {% trans %}Edit Reply{% endtrans %}
 {%- endif %}
 {%- endmacro %}
 
 
 {% macro get_info() -%}
-{% if mode == 'edit_post' -%}
+{% if action == 'edit_reply' -%}
     {% if post.moderated %}<li><i class="icon-eye-close"></i> {% trans %}Not Reviewed{% endtrans %}</li>{% endif %}
     <li><i class="icon-time"></i> {{ post.date|reltimesince }}</li>
     <li><i class="icon-user"></i> {% if post.user %}<a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}">{{ post.user.username }}</a>{% else %}{{ post.user_name }}{% endif %}</li>
@@ -180,14 +168,12 @@
 
 
 {% macro get_button() -%}
-{% if mode == 'new_thread' -%}
+{% if action == 'new_thread' -%}
 {% trans %}Post Thread{% endtrans %}
-{%- elif mode == 'edit_thread' -%}
-{% trans %}Edit Thread{% endtrans %}
-{%- elif mode in ['new_post', 'new_post_quick'] -%}
+{%- elif action == 'new_reply' -%}
 {% trans %}Post Reply{% endtrans %}
-{%- elif mode == 'edit_post' -%}
-{% trans %}Edit Reply{% endtrans %}
+{%- else -%}
+{% trans %}Save Changes{% endtrans %}
 {%- endif %}
 {%- endmacro %}
 

+ 51 - 46
templates/cranefly/threads/thread.html

@@ -16,7 +16,7 @@
 <div class="page-header header-primary">
   <div class="container">
     {{ messages_list(messages) }}
-    <ul class="breadcrumb">
+    <ul class="breadcrumb" {{ macros.itemprop_bread() }}>
       {{ self.breadcrumb() }}</li>
     </ul>
     <h1>{{ thread.name }}</h1>
@@ -82,7 +82,7 @@
           <div class="post-header">
             <div class="post-header-compact">
               {% if post.user_id %}
-              <a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}" class="post-author">{{ post.user.username }}</a>{% if post.user.get_title() %} <span class="label post-author-label{% if post.user.rank and post.user.rank.style %} post-label-{{ post.user.rank.style }}{% endif %}">{{ post.user.get_title() }}</span>{% endif %}
+              <a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}" class="post-author">{{ post.user.username }}</a>{% if post.user.get_title() %} {{ user_label(post.user) }}{% endif %}
               {% else %}
               <span class="post-author">{{ post.user_name }}</span> <span class="label post-author-label post-label-guest">{% trans %}Unregistered{% endtrans %}</span>
               {% endif %}
@@ -167,7 +167,7 @@
         <div class="post-content">
           <div class="post-header">
             {% if post.user_id %}
-            <a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}" class="post-author">{{ post.user.username }}</a>{% if post.user.get_title() %} <span class="label post-author-label{% if post.user.rank and post.user.rank.style %} post-label-{{ post.user.rank.style }}{% endif %}">{{ post.user.get_title() }}</span>{% endif %}
+            <a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}" class="post-author">{{ post.user.username }}</a>{% if post.user.get_title() %} {{ user_label(post.user) }}{% endif %}
             {% else %}
             <span class="post-author">{{ post.user_name }}</span> <span class="label post-author-label post-label-guest">{% trans %}Unregistered{% endtrans %}</span>
             {% endif %}
@@ -180,7 +180,7 @@
             {% if post.edits %}
             <span class="separator">&ndash;</span>
             {% if acl.threads.can_see_changelog(user, forum, post) %}
-            <a href="{% url 'changelog' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="post-changelog tooltip-bottom" title="{% trans %}Show changelog{% endtrans %}">{% trans count=post.edits %}One edit{% pluralize %}{{ count }} edits{% endtrans %}</a>
+            <a href="{% url 'thread_changelog' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="post-changelog tooltip-bottom" title="{% trans %}Show changelog{% endtrans %}">{% trans count=post.edits %}One edit{% pluralize %}{{ count }} edits{% endtrans %}</a>
             {% else %}
             <span class="post-changelog">{% trans count=post.edits %}One edit{% pluralize %}{{ count }} edits{% endtrans %}</span>
             {% endif %}
@@ -242,47 +242,41 @@
           </div>
           <div class="post-footer">{% filter trim %}
             {% if acl.threads.can_see_post_score(forum) %}
-            <div class="post-rating">
-              {% if acl.threads.can_see_post_score(forum) == 1 %}
-              <span class="post-score{% if (post.upvotes - post.downvotes) > 0 %} post-score-good{% elif (post.upvotes - post.downvotes) < 0 %} post-score-bad{% endif %}">{{ post.upvotes - post.downvotes }}</span>
-              {% elif acl.threads.can_see_post_score(forum) == 2%}
-              <span class="post-score{% if post.upvotes %} post-score-good{% endif %}">{{ post.upvotes }}</span>
-              {% endif %}
-              {% if user.is_authenticated() and user.pk != post.user_id and acl.threads.can_upvote_posts(forum) %}
-              {% if post.karma_vote and post.karma_vote.score > 0 %}
-              <span class="post-like">{% trans %}Like{% endtrans %}</span>
-              {% else %}
-              <form action="{% url 'post_upvote' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="form-inline" method="post">
-                <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
-                <button type="submit" class="btn btn-link post-like">{% trans %}Like{% endtrans %}</button>
-              </form>
-              {% endif %}
-              {% else %}
-              <span class="post-{% if post.upvotes %}like{% else %}neutral{% endif %}">{% trans %}Likes{% endtrans %}</span>
-              {% endif %}
-            {% if acl.threads.can_see_post_score(forum) == 2 %}
-            </div>
-            <div class="post-rating">
-              <span class="post-score{% if post.downvotes %} post-score-bad{% endif %}">{{ post.downvotes }}</span>
-            {% endif %}
-              {% if user.is_authenticated() and user.pk != post.user_id and acl.threads.can_downvote_posts(forum) %}
-              {% if post.karma_vote and post.karma_vote.score < 0 %}
-              <span class="post-hate">{% trans %}Hate{% endtrans %}</span>
-              {% else %}
-              <form action="{% url 'post_downvote' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="form-inline" method="post">
-                <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
-                <button type="submit" class="btn btn-link post-hate">{% trans %}Hate{% endtrans %}</button>
-              </form>
+            <div{% if user.is_authenticated() and user.pk != post.user_id %} class="post-rating-actions"{% endif %}>
+              <div class="post-rating">
+                {% if acl.threads.can_see_post_score(forum) == 1 %}
+                <span class="post-score post-score-total{% if (post.upvotes - post.downvotes) > 0 %} post-score-good{% elif (post.upvotes - post.downvotes) < 0 %} post-score-bad{% endif %}">{{ post.upvotes - post.downvotes }}</span>
+                {% elif acl.threads.can_see_post_score(forum) == 2%}
+                <span class="post-score post-score-upvotes{% if post.upvotes %} post-score-good{% endif %}">{{ post.upvotes }}</span>
+                {% endif %}
+                {% if user.is_authenticated() and user.pk != post.user_id and acl.threads.can_upvote_posts(forum) %}
+                <form action="{% url 'post_upvote' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="form-inline form-upvote" method="post">
+                  <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+                  <button type="submit" class="btn btn-link post-like"{% if post.karma_vote and post.karma_vote.score > 0 %} disabled="disabled"{% endif %}>{% trans %}Like{% endtrans %}</button>
+                </form>
+                {% else %}
+                <span class="post-{% if post.upvotes %}like{% else %}neutral{% endif %}">{% trans %}Likes{% endtrans %}</span>
+                {% endif %}
+              {% if acl.threads.can_see_post_score(forum) == 2 %}
+              </div>
+              <div class="post-rating">
+                <span class="post-score post-score-downvotes{% if post.downvotes %} post-score-bad{% endif %}">{{ post.downvotes }}</span>
               {% endif %}
-              {% elif acl.threads.can_see_post_score(forum) == 2 %}
-              <span class="post-{% if post.downvotes %}hate{% else %}neutral{% endif %}">{% trans %}Hates{% endtrans %}</span>
+                {% if user.is_authenticated() and user.pk != post.user_id and acl.threads.can_downvote_posts(forum) %}
+                <form action="{% url 'post_downvote' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="form-inline form-downvote" method="post">
+                  <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+                  <button type="submit" class="btn btn-link post-hate"{% if post.karma_vote and post.karma_vote.score < 0 %} disabled="disabled"{% endif %}>{% trans %}Hate{% endtrans %}</button>
+                </form>
+                {% elif acl.threads.can_see_post_score(forum) == 2 %}
+                <span class="post-{% if post.downvotes %}hate{% else %}neutral{% endif %}">{% trans %}Hates{% endtrans %}</span>
+                {% endif %}
+              </div>
+              {% if acl.threads.can_see_post_votes(forum) %}
+              <div class="post-rating">
+                <a href="{% url 'post_votes' thread=thread.pk, slug=thread.slug, post=post.pk %}">{% trans %}Show Votes{% endtrans %}</a>
+              </div>
               {% endif %}
             </div>
-            {% if acl.threads.can_see_post_votes(forum) %}
-            <div class="post-rating">
-              <a href="{% url 'post_votes' thread=thread.pk, slug=thread.slug, post=post.pk %}">{% trans %}Show Votes{% endtrans %}</a>
-            </div>
-            {% endif %}
             {% endif %}
 
             {% if user.is_authenticated() %}
@@ -308,8 +302,11 @@
               {% endif %}
               {% if not post.deleted and acl.threads.can_delete_thread(user, forum, thread, post) %}
               <form action="{% url 'thread_hide' thread=thread.pk, slug=thread.slug %}" class="form-inline prompt-delete-thread" method="post">
-                <button type="submit" class="btn btn-link tooltip-top" title="{% trans %}Hide this thread from other users{% endtrans %}">{% trans %}Soft{% endtrans %}</button>
                 <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+                {% if acl.threads.can_delete_thread(user, forum, thread, post) != 2 %}
+                <span>{% trans %}Delete thread:{% endtrans %}</span>
+                {% endif %}
+                <button type="submit" class="btn btn-link tooltip-top" title="{% trans %}Hide this thread from other users{% endtrans %}">{% trans %}Soft{% endtrans %}</button>
               </form>
               {% endif %}
             </div>
@@ -325,6 +322,9 @@
               {% if not post.deleted and acl.threads.can_delete_post(user, forum, thread, post) %}
               <form action="{% url 'post_hide' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="form-inline prompt-delete-post" method="post">
                 <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+                {% if acl.threads.can_delete_post(user, forum, thread, post) != 2 %}
+                <span>{% trans %}Delete reply:{% endtrans %}</span>
+                {% endif %}
                 <button type="submit" class="btn btn-link tooltip-top" title="{% trans %}Hide this reply from other users{% endtrans %}">{% trans %}Soft{% endtrans %}</button>
               </form>
               {% endif %}
@@ -399,7 +399,7 @@
     <form action="{% url 'thread_reply' thread=thread.pk, slug=thread.slug %}" method="post">
       <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
       <input type="hidden" name="quick_reply" value="1">
-      <img src="{{ user.get_avatar() }}" alt="{% trans %}Your Avatar{% endtrans %}" class="user-avatar">
+      <img src="{{ user.get_avatar(100) }}" alt="{% trans %}Your Avatar{% endtrans %}" class="user-avatar">
       {{ editor.editor(quick_reply.post, _('Post Reply'), extra=editor_extra()) }}
     </form>
   </div>
@@ -463,6 +463,11 @@
 {%- endblock %}
 
 
+{% macro user_label(user) -%}
+<{% if user.rank and user.rank.as_tab %}a href="{% url 'users' slug=user.rank.slug %}"{% else %}span{% endif %} class="label post-author-label{% if user.rank and user.rank.style %} post-label-{{ user.rank.style }}{% endif %}">{{ user.get_title() }}</{% if user.rank and user.rank.as_tab%}a{% else %}span{% endif %}>
+{%- endmacro %}
+
+
 {% macro pager(extra=true) %}
 <div class="pagination pull-left">
   <ul>
@@ -485,7 +490,7 @@
 
 {% macro checkpoint_user(checkpoint) -%}
 {%- if checkpoint.user_id -%}
-{{ ('<a href="' ~ 'user'|url(user=checkpoint.user_id, username=checkpoint.user_slug) ~ '">')|safe ~ (checkpoint.user_name) ~ ("</a>")|safe }}
+<a href="{{ 'user'|url(user=checkpoint.user_id, username=checkpoint.user_slug) }}">{{ checkpoint.user_name }}</a>
 {%- else -%}
 <strong>{{ checkpoint.user_name }}</strong>
 {%- endif -%}
@@ -494,7 +499,7 @@
 
 {% macro edit_user(post) -%}
 {%- if post.edit_user_id -%}
-{{ ('<a href="' ~ 'user'|url(user=post.edit_user_id, username=post.edit_user_slug) ~ '">')|safe ~ (post.edit_user_name) ~ ("</a>")|safe }}
+<a href="{{ 'user'|url(user=post.edit_user_id, username=post.edit_user_slug) }}">{{ post.edit_user_name }}</a>
 {%- else -%}
 <strong>{{ post.edit_user_name }}</strong>
 {%- endif -%}

+ 2 - 2
templates/cranefly/watched.html

@@ -44,12 +44,12 @@
             {%- endif %}"><i class="icon-comment"></i></a>
             <a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}" class="thread-name">{{ thread.name }}</a>
             <span class="thread-details">
-              {% trans user=thread_starter(thread), forum=thread_forum(thread), start=thread.start|reldate|low %}by {{ user }} in {{ forum }} {{ start }}{% endtrans %}
+              {% trans user=thread_starter(thread), forum=thread_forum(thread), start=thread.start|reltimesince|low %}by {{ user }} in {{ forum }} {{ start }}{% endtrans %}
             </span>
           </td>
           <td>
             <div class="thread-last-reply">
-              {{ replies(thread.replies) }} - {% trans user=thread_reply(thread), last=thread.last|reldate|low %}last by {{ user }} {{ last }}{% endtrans %}
+              {{ replies(thread.replies) }} - {% trans user=thread_reply(thread), last=thread.last|reltimesince|low %}last by {{ user }} {{ last }}{% endtrans %}
             </div>
           </td>
           <td class="watched-thread-flags">

+ 3 - 3
templates/debug_toolbar/base.html

@@ -1,10 +1,10 @@
 {% load i18n %}
 <style type="text/css">
 @media print { #djDebug {display:none;}}
-{{ css }}
 </style>
-<script type="text/javascript">{{ js }}</script>
-<div id="djDebug" style="display:none;">
+<link rel="stylesheet" href="{{ STATIC_URL }}debug_toolbar/css/toolbar.min.css" type="text/css">
+<script type="text/javascript" src="{{ STATIC_URL }}debug_toolbar/js/toolbar.min.js"></script>
+<div id="djDebug" style="display:none;" dir="ltr">
 	<div style="display:none;" id="djDebugToolbar">
 		<ul id="djDebugPanelList">
 			{% if panels %}

+ 51 - 38
templates/debug_toolbar/panels/cache.html

@@ -1,56 +1,69 @@
 {% load i18n %}
+<h3>{% trans "Summary" %}</h3>
 <table>
-	<colgroup>
-		<col width="12%"/>
-		<col width="12%"/>
-		<col width="12%"/>
-		<col width="12%"/>
-		<col width="12%"/>
-		<col width="12%"/>
-		<col width="12%"/>
-		<col width="12%"/>
-	</colgroup>
+	<thead>
 	<tr>
 		<th>{% trans "Total Calls" %}</th>
-		<td>{{ cache_calls }}</td>
 		<th>{% trans "Total Time" %}</th>
-		<td>{{ cache_time }}ms</td>
-		<th>{% trans "Hits" %}</th>
-		<td>{{ cache.hits }}</td>
-		<th>{% trans "Misses" %}</th>
-		<td>{{ cache.misses }}</td>
+		<th>{% trans "Cache Hits" %}</th>
+		<th>{% trans "Cache Misses" %}</th>
+	</tr>
+	</thead>
+	<tbody>
+	<tr>
+		<td>{{ total_calls }}</td>
+		<td>{{ total_time }} ms</td>
+		<td>{{ hits }}</td>
+		<td>{{ misses }}</td>
+	</tr>
+	</tbody>
+</table>
+<h3>{% trans "Commands" %}</h3>
+<table>
+	<thead>
+	<tr>
+	{% for name in counts.iterkeys %}
+		<th>{{ name }}</th>
+	{% endfor %}
 	</tr>
+	</thead>
+	<tbody>
 	<tr>
-		<th>gets</th>
-		<td>{{ cache.gets }}</td>
-		<th>sets</th>
-		<td>{{ cache.sets }}</td>
-		<th>deletes</th>
-		<td>{{ cache.deletes }}</td>
-		<th>get_many</th>
-		<td>{{ cache.get_many }}</td>
+	{% for value in counts.itervalues %}
+		<td>{{ value }}</td>
+	{% endfor %}
 	</tr>
+	</tbody>
 </table>
-{% if cache.calls %}
-<h3>{% trans "Breakdown" %}</h3>
+{% if calls %}
+<h3>{% trans "Calls" %}</h3>
 <table>
 	<thead>
 		<tr>
-			<th>{% trans "Time" %}&nbsp;(ms)</th>
+			<th colspan="2">{% trans "Time (ms)" %}</th>
 			<th>{% trans "Type" %}</th>
-			<th>{% trans "Parameters" %}</th>
-			<th>{% trans "Function" %}</th>
+			<th>{% trans "args" %}</th>
+			<th>{% trans "kwargs" %}</th>
+			<th>{% trans "Backend" %}</th>
 		</tr>
 	</thead>
 	<tbody>
-		{% for query in cache.calls %}
-			<tr class="{% cycle 'row1' 'row2' %}">
-				<td>{{ query.0|floatformat:"4" }}</td>
-				<td>{{ query.1|escape }}</td>
-				<td>{{ query.2|escape }}</td>
-				<td><acronym title="{{ query.3.0 }}:{{ query.3.1 }}">{{ query.3.2|escape }}</acronym>: {{ query.3.3.0|escape }}</td>
-			</tr>
-		{% endfor %}
+	{% for call in calls %}
+		<tr class="{% cycle 'djDebugOdd' 'djDebugEven' %}" id="cacheMain_{{ forloop.counter }}">
+			<td class="toggle">
+				<a class="djToggleSwitch" data-toggle-name="cacheMain" data-toggle-id="{{ forloop.counter }}" data-toggle-open="+" data-toggle-close="-" href="javascript:void(0)">+</a>
+			</td>
+			<td>{{ call.time|floatformat:"4" }}</td>
+			<td>{{ call.name|escape }}</td>
+			<td>{{ call.args|escape }}</td>
+			<td>{{ call.kwargs|escape }}</td>
+			<td>{{ call.backend }}</td>
+		</tr>
+		<tr class="djUnselected djDebugHoverable {% cycle 'djDebugOdd' 'djDebugEven' %} djToggleDetails_{{ forloop.counter }}" id="cacheDetails_{{ forloop.counter }}">
+			<td colspan="1"></td>
+			<td colspan="5"><pre class="stack">{{ call.trace }}</pre></td>
+		</tr>
+	{% endfor %}
 	</tbody>
 </table>
-{% endif %}
+{% endif %}

+ 1 - 1
templates/debug_toolbar/panels/logger.html

@@ -16,7 +16,7 @@
 					<td>{{ record.level }}</td>
 					<td>{{ record.time|date:"h:i:s m/d/Y" }}</td>
 					<td>{{ record.channel|default:"-" }}</td>
-					<td>{{ record.message }}</td>
+					<td>{{ record.message|linebreaksbr }}</td>
 					<td>{{ record.file }}:{{ record.line }}</td>
 				</tr>
 			{% endfor %}

+ 13 - 6
templates/debug_toolbar/panels/profiling.html

@@ -4,17 +4,17 @@
 	<thead>
 		<tr>
 			<th>{% trans "Call" %}</th>
-			<th>{% trans "TotTime" %}</th>
-			<th>{% trans "Per" %}</th>
 			<th>{% trans "CumTime" %}</th>
 			<th>{% trans "Per" %}</th>
+			<th>{% trans "TotTime" %}</th>
+			<th>{% trans "Per" %}</th>
 			<th>{% trans "Count" %}</th>
 		</tr>
 	</thead>
 	<tbody>
 		{% for call in func_list %}
 			<!--  style="background:{{ call.background }}" -->
-			<tr id="{{ call.id }}" class="djDebugProfileRow{% for parent_id in call.parent_ids %} djToggleDetails_{{ parent_id }}{% endfor %}" depth="{{ call.depth }}">
+			<tr class="djDebugProfileRow{% for parent_id in call.parent_ids %} djToggleDetails_{{ parent_id }}{% endfor %}" depth="{{ call.depth }}">
 				<td>
 					<div style="padding-left: {{ call.indent }}px;">
 						{% if call.has_subfuncs %}
@@ -25,12 +25,19 @@
 						<span class="stack">{{ call.func_std_string }}</span>
 					</div>
 				</td>
-				<td>{{ call.tottime|floatformat:3 }}</td>
-				<td>{{ call.tottime_per_call|floatformat:3 }}</td>
 				<td>{{ call.cumtime|floatformat:3 }}</td>
 				<td>{{ call.cumtime_per_call|floatformat:3 }}</td>
+				<td>{{ call.tottime|floatformat:3 }}</td>
+				<td>{{ call.tottime_per_call|floatformat:3 }}</td>
 				<td>{{ call.count }}</td>
 			</tr>
+			{% if call.line_stats_text %}
+				<tr class="djToggleDetails_{{ call.id }}{% for parent_id in call.parent_ids %} djToggleDetails_{{ parent_id }}{% endfor %}">
+					<td colspan="6">
+						<div style="padding-left: {{ call.indent }}px;"><pre>{{ call.line_stats_text }}</pre></div>
+					</td>
+				</tr>
+			{% endif %}
 		{% endfor %}
 	</tbody>
-</table>
+</table>

+ 2 - 0
templates/debug_toolbar/panels/request_vars.html

@@ -5,6 +5,7 @@
 	<thead>
 		<tr>
 			<th>{% trans 'View Function' %}</th>
+			<th>{% trans 'URL Name' %}</th>
 			<th>{% trans 'args' %}</th>
 			<th>{% trans 'kwargs' %}</th>
 		</tr>
@@ -12,6 +13,7 @@
 	<tbody>
 		<tr>
 			<td>{{ view_func }}</td>
+			<td>{{ view_urlname }}</td>
 			<td>{{ view_args|default:"None" }}</td>
 			<td>
 			{% if view_kwargs.items %}

+ 3 - 3
templates/debug_toolbar/panels/settings_vars.html

@@ -7,10 +7,10 @@
 		</tr>
 	</thead>
 	<tbody>
-		{% for var in settings.items|dictsort:"0" %}
+		{% for name, value in settings.items %}
 			<tr class="{% cycle 'djDebugOdd' 'djDebugEven' %}">
-				<td>{{ var.0 }}</td>
-				<td><code>{{ var.1|pprint }}</code></td>
+				<td>{{ name }}</td>
+				<td><code>{{ value|pprint }}</code></td>
 			</tr>
 		{% endfor %}
 	</tbody>

+ 1 - 1
templates/debug_toolbar/panels/signals.html

@@ -2,7 +2,7 @@
 <table>
 	<thead>
 		<tr>
-			<th>{% trans "Signal" %}</th>
+			<th>{% trans 'Signal' %}</th>
 			<th>{% trans 'Providing Args' %}</th>
 			<th>{% trans 'Receivers' %}</th>
 		</tr>

+ 11 - 10
templates/debug_toolbar/panels/sql.html

@@ -1,4 +1,5 @@
 {% load i18n %}
+{% load debug_toolbar_utils %}
 <div class="clearfix">
 	<ul class="stats">
 		{% for alias, info in databases %}
@@ -26,7 +27,7 @@
 				<tr class="djDebugHoverable {% cycle 'djDebugOdd' 'djDebugEven' %}{% if query.is_slow %} djDebugRowWarning{% endif %}{% if query.starts_trans %} djDebugStartTransaction{% endif %}{% if query.ends_trans %} djDebugEndTransaction{% endif %}{% if query.in_trans %} djDebugInTransaction{% endif %}" id="sqlMain_{{ forloop.counter }}">
 					<td class="color"><span style="background-color: rgb({{ query.rgb_color|join:", " }});">&nbsp;</span></td>
 					<td class="toggle">
-						<a class="djToggleSwitch" data-toggle-id="{{ forloop.counter }}" data-toggle-open="+" data-toggle-close="-" href="javascript:void(0)">+</a>
+						<a class="djToggleSwitch" data-toggle-name="sqlMain" data-toggle-id="{{ forloop.counter }}" data-toggle-open="+" data-toggle-close="-" href="javascript:void(0)">+</a>
 					</td>
 					<td class="query">
 						<div class="djDebugSqlWrap">
@@ -34,7 +35,7 @@
 						</div>
 					</td>
 					<td class="timeline">
-						<div class="djDebugTimeline"><div class="djDebugLineChart{% if query.is_slow %} djDebugLineChartWarning{% endif %}" style="left:{{ query.start_offset }}%;"><strong style="width:{{ query.width_ratio }}%;">{{ query.width_ratio }}%</strong></div></div>
+						<div class="djDebugTimeline"><div class="djDebugLineChart{% if query.is_slow %} djDebugLineChartWarning{% endif %}" style="left:{{ query.start_offset|dotted_number }}%;"><strong style="width:{{ query.width_ratio_relative|dotted_number }}%;">{{ query.width_ratio }}%</strong></div></div>
 					</td>
 					<td class="time">
 						{{ query.duration|floatformat:"2" }}
@@ -42,10 +43,10 @@
 					<td class="actions">
 					{% if query.params %}
 						{% if query.is_select %}
-							<a class="remoteCall" href="/__debug__/sql_select/?sql={{ query.raw_sql|urlencode }}&amp;params={{ query.params|urlencode }}&amp;duration={{ query.duration|floatformat:"2"|urlencode }}&amp;hash={{ query.hash }}">Sel</a>
-							<a class="remoteCall" href="/__debug__/sql_explain/?sql={{ query.raw_sql|urlencode }}&amp;params={{ query.params|urlencode }}&amp;duration={{ query.duration|floatformat:"2"|urlencode }}&amp;hash={{ query.hash }}">Expl</a>
+							<a class="remoteCall" href="/__debug__/sql_select/?sql={{ query.raw_sql|urlencode }}&amp;params={{ query.params|urlencode }}&amp;duration={{ query.duration|floatformat:"2"|urlencode }}&amp;hash={{ query.hash }}&amp;alias={{ query.alias|urlencode }}">Sel</a>
+							<a class="remoteCall" href="/__debug__/sql_explain/?sql={{ query.raw_sql|urlencode }}&amp;params={{ query.params|urlencode }}&amp;duration={{ query.duration|floatformat:"2"|urlencode }}&amp;hash={{ query.hash }}&amp;alias={{ query.alias|urlencode }}">Expl</a>
 							{% ifequal query.engine 'mysql' %}
-								<a class="remoteCall" href="/__debug__/sql_profile/?sql={{ query.raw_sql|urlencode }}&amp;params={{ query.params|urlencode }}&amp;duration={{ query.duration|floatformat:"2"|urlencode }}&amp;hash={{ query.hash }}">Prof</a>
+								<a class="remoteCall" href="/__debug__/sql_profile/?sql={{ query.raw_sql|urlencode }}&amp;params={{ query.params|urlencode }}&amp;duration={{ query.duration|floatformat:"2"|urlencode }}&amp;hash={{ query.hash }}&amp;alias={{ query.alias|urlencode }}">Prof</a>
 							{% endifequal %}
 						{% endif %}
 					{% endif %}
@@ -55,12 +56,12 @@
 					<td colspan="2"></td>
 					<td colspan="4">
 						<div class="djSQLDetailsDiv">
-							<p><strong>Connection:</strong> {{ query.alias }}</p>
+							<p><strong>{% trans "Connection:" %}</strong> {{ query.alias }}</p>
 							{% if query.iso_level %}
-								<p><strong>Isolation Level:</strong> {{ query.iso_level }}</p>
+								<p><strong>{% trans "Isolation level:" %}</strong> {{ query.iso_level }}</p>
 							{% endif %}
 							{% if query.trans_status %}
-								<p><strong>Transaction Status:</strong> {{ query.trans_status }}</p>
+								<p><strong>{% trans "Transaction status:" %}</strong> {{ query.trans_status }}</p>
 							{% endif %}
 							{% if query.stacktrace %}
 								<pre class="stack">{{ query.stacktrace }}</pre>
@@ -74,7 +75,7 @@
 									</tr>
 									{% endfor %}
 								</table>
-								<p><strong>{{ query.template_info.name|default:"(unknown)" }}</strong></p>
+								<p><strong>{{ query.template_info.name|default:_("(unknown)") }}</strong></p>
 							{% endif %}
 						</div>
 					</td>
@@ -83,5 +84,5 @@
 		</tbody>
 	</table>
 {% else %}
-	<p>No SQL queries were recorded during this request.</p>
+	<p>{% trans "No SQL queries were recorded during this request." %}</p>
 {% endif %}

+ 2 - 0
templates/debug_toolbar/panels/sql_explain.html

@@ -10,6 +10,8 @@
 			<dd>{{ sql|safe }}</dd>
 			<dt>{% trans "Time" %}</dt>
 			<dd>{{ duration }} ms</dd>
+			<dt>{% trans "Database" %}</dt>
+			<dd>{{ alias }}</dd>
 		</dl>
 		<table class="djSqlExplain">
 			<thead>

+ 2 - 0
templates/debug_toolbar/panels/sql_profile.html

@@ -11,6 +11,8 @@
 				<dd>{{ sql|safe }}</dd>
 				<dt>{% trans "Time" %}</dt>
 				<dd>{{ duration }} ms</dd>
+				<dt>{% trans "Database" %}</dt>
+				<dd>{{ alias }}</dd>
 			</dl>
 			<table class="djSqlProfile">
 				<thead>

+ 2 - 0
templates/debug_toolbar/panels/sql_select.html

@@ -10,6 +10,8 @@
 			<dd>{{ sql|safe }}</dd>
 			<dt>{% trans "Time" %}</dt>
 			<dd>{{ duration }} ms</dd>
+			<dt>{% trans "Database" %}</dt>
+			<dd>{{ alias }}</dd>
 		</dl>
 		{% if result %}
 		<table class="djSqlSelect">

+ 4 - 4
templates/debug_toolbar/panels/templates.html

@@ -1,5 +1,5 @@
 {% load i18n %}
-<h4>{% trans 'Template path' %}{{ template_dirs|length|pluralize }}</h4>
+<h4>{% blocktrans count template_dirs|length as template_count %}Template path{% plural %}Template paths{% endblocktrans %}</h4>
 {% if template_dirs %}
 	<ol>
 	{% for template in template_dirs %}
@@ -7,10 +7,10 @@
 	{% endfor %}
 	</ol>
 {% else %}
-	<p>None</p>
+	<p>{% trans "None" %}</p>
 {% endif %}
 
-<h4>{% trans "Template" %}{{ templates|length|pluralize }}</h4>
+<h4>{% blocktrans count templates|length as template_count %}Template{% plural %}Templates{% endblocktrans %}</h4>
 {% if templates %}
 <dl>
 {% for template in templates %}
@@ -28,7 +28,7 @@
 	<p>{% trans 'None' %}</p>
 {% endif %}
 
-<h4>{% trans 'Context processor' %}{{ context_processors|length|pluralize }}</h4>
+<h4>{% blocktrans count context_processors|length as context_processors_count %}Context processor{% plural %}Context processors{% endblocktrans %}</h4>
 {% if context_processors %}
 <dl>
 {% for key, value in context_processors.iteritems %}

+ 1 - 2
templates/debug_toolbar/panels/versions.html

@@ -1,9 +1,8 @@
 {% load i18n %}
-
 <table>
 	<thead>
 		<tr>
-			<th>{% trans "Package" %}</th>
+			<th>{% trans "Name" %}</th>
 			<th>{% trans "Version" %}</th>
 		</tr>
 	</thead>

+ 4 - 1
templates/debug_toolbar/redirect.html

@@ -4,9 +4,12 @@
 </head>
 <body>
 <h1>HttpResponseRedirect</h1>
-<p>{% trans 'Location' %}: <a href="{{ redirect_to }}">{{ redirect_to }}</a></p>
+<p>{% trans 'Location' %}: <a id="redirect_to" href="{{ redirect_to }}">{{ redirect_to }}</a></p>
 <p class="notice">
 	{% trans "The Django Debug Toolbar has intercepted a redirect to the above URL for debug viewing purposes.  You can click the above link to continue with the redirect as normal.  If you'd like to disable this feature, set the <code>DEBUG_TOOLBAR_CONFIG</code> dictionary's key <code>INTERCEPT_REDIRECTS</code> to <code>False</code>." %}
 </p>
+<script type="text/javascript">
+    document.getElementById('redirect_to').focus();
+</script>
 </body>
 </html>