Browse Source

Misago works after refactoring

Ralfp 12 years ago
parent
commit
e88b13ce0a
102 changed files with 4140 additions and 62 deletions
  1. 0 0
      misago/apps/activation/__init__.py
  2. 32 0
      misago/apps/activation/forms.py
  3. 6 0
      misago/apps/activation/urls.py
  4. 87 0
      misago/apps/activation/views.py
  5. 51 0
      misago/apps/alerts.py
  6. 22 0
      misago/apps/category.py
  7. 9 0
      misago/apps/forummap.py
  8. 22 0
      misago/apps/newsfeed.py
  9. 22 0
      misago/apps/newthreads.py
  10. 22 0
      misago/apps/popularthreads.py
  11. 0 0
      misago/apps/profiles/__init__.py
  12. 37 0
      misago/apps/profiles/decorators.py
  13. 0 0
      misago/apps/profiles/details/__init__.py
  14. 4 0
      misago/apps/profiles/details/profile.py
  15. 14 0
      misago/apps/profiles/details/urls.py
  16. 10 0
      misago/apps/profiles/details/views.py
  17. 0 0
      misago/apps/profiles/followers/__init__.py
  18. 4 0
      misago/apps/profiles/followers/profile.py
  19. 16 0
      misago/apps/profiles/followers/urls.py
  20. 18 0
      misago/apps/profiles/followers/views.py
  21. 0 0
      misago/apps/profiles/follows/__init__.py
  22. 4 0
      misago/apps/profiles/follows/profile.py
  23. 16 0
      misago/apps/profiles/follows/urls.py
  24. 18 0
      misago/apps/profiles/follows/views.py
  25. 5 0
      misago/apps/profiles/forms.py
  26. 0 0
      misago/apps/profiles/posts/__init__.py
  27. 4 0
      misago/apps/profiles/posts/profile.py
  28. 16 0
      misago/apps/profiles/posts/urls.py
  29. 17 0
      misago/apps/profiles/posts/views.py
  30. 55 0
      misago/apps/profiles/template.py
  31. 0 0
      misago/apps/profiles/threads/__init__.py
  32. 4 0
      misago/apps/profiles/threads/profile.py
  33. 16 0
      misago/apps/profiles/threads/urls.py
  34. 18 0
      misago/apps/profiles/threads/views.py
  35. 25 0
      misago/apps/profiles/urls.py
  36. 87 0
      misago/apps/profiles/views.py
  37. 21 0
      misago/apps/redirect.py
  38. 0 0
      misago/apps/register/__init__.py
  39. 79 0
      misago/apps/register/forms.py
  40. 84 0
      misago/apps/register/views.py
  41. 0 0
      misago/apps/resetpswd/__init__.py
  42. 32 0
      misago/apps/resetpswd/forms.py
  43. 6 0
      misago/apps/resetpswd/urls.py
  44. 89 0
      misago/apps/resetpswd/views.py
  45. 1 1
      misago/apps/signin/views.py
  46. 0 0
      misago/apps/threads/__init__.py
  47. 225 0
      misago/apps/threads/forms.py
  48. 34 0
      misago/apps/threads/urls.py
  49. 8 0
      misago/apps/threads/views/__init__.py
  50. 14 0
      misago/apps/threads/views/base.py
  51. 125 0
      misago/apps/threads/views/changelog.py
  52. 105 0
      misago/apps/threads/views/delete.py
  53. 40 0
      misago/apps/threads/views/details.py
  54. 239 0
      misago/apps/threads/views/jumps.py
  55. 42 0
      misago/apps/threads/views/karmas.py
  56. 392 0
      misago/apps/threads/views/list.py
  57. 434 0
      misago/apps/threads/views/posting.py
  58. 565 0
      misago/apps/threads/views/thread.py
  59. 8 0
      misago/apps/tos.py
  60. 0 0
      misago/apps/usercp/__init__.py
  61. 0 0
      misago/apps/usercp/avatar/__init__.py
  62. 32 0
      misago/apps/usercp/avatar/forms.py
  63. 21 0
      misago/apps/usercp/avatar/urls.py
  64. 4 0
      misago/apps/usercp/avatar/usercp.py
  65. 228 0
      misago/apps/usercp/avatar/views.py
  66. 0 0
      misago/apps/usercp/credentials/__init__.py
  67. 52 0
      misago/apps/usercp/credentials/forms.py
  68. 19 0
      misago/apps/usercp/credentials/urls.py
  69. 4 0
      misago/apps/usercp/credentials/usercp.py
  70. 72 0
      misago/apps/usercp/credentials/views.py
  71. 0 0
      misago/apps/usercp/options/__init__.py
  72. 41 0
      misago/apps/usercp/options/forms.py
  73. 11 0
      misago/apps/usercp/options/urls.py
  74. 4 0
      misago/apps/usercp/options/usercp.py
  75. 39 0
      misago/apps/usercp/options/views.py
  76. 0 0
      misago/apps/usercp/signature/__init__.py
  77. 16 0
      misago/apps/usercp/signature/forms.py
  78. 12 0
      misago/apps/usercp/signature/urls.py
  79. 5 0
      misago/apps/usercp/signature/usercp.py
  80. 47 0
      misago/apps/usercp/signature/views.py
  81. 25 0
      misago/apps/usercp/template.py
  82. 22 0
      misago/apps/usercp/urls.py
  83. 0 0
      misago/apps/usercp/username/__init__.py
  84. 31 0
      misago/apps/usercp/username/forms.py
  85. 11 0
      misago/apps/usercp/username/urls.py
  86. 5 0
      misago/apps/usercp/username/usercp.py
  87. 71 0
      misago/apps/usercp/username/views.py
  88. 76 0
      misago/apps/usercp/views.py
  89. 0 0
      misago/apps/watchedthreads/__init__.py
  90. 8 0
      misago/apps/watchedthreads/urls.py
  91. 40 0
      misago/apps/watchedthreads/views.py
  92. 2 2
      misago/auth.py
  93. 0 4
      misago/decorators.py
  94. 0 18
      misago/forms/captcha.py
  95. 1 0
      misago/forms/fields.py
  96. 2 1
      misago/models/banmodel.py
  97. 3 1
      misago/models/forumreadmodel.py
  98. 1 1
      misago/models/postmodel.py
  99. 2 2
      misago/models/threadmodel.py
  100. 10 10
      misago/settings_base.py
  101. 18 22
      misago/urls.py
  102. 1 0
      misago/utils/datesformats.py

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


+ 32 - 0
misago/apps/activation/forms.py

@@ -0,0 +1,32 @@
+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, QACaptchaField, ReCaptchaField
+from misago.models import User
+
+class UserSendActivationMailForm(Form):
+    email = forms.EmailField(max_length=255)
+    captcha_qa = QACaptchaField()
+    recaptcha = ReCaptchaField()
+    error_source = 'email'
+
+    layout = [
+              (
+               None,
+               [('email', {'label': _("Your E-mail Address"), 'help_text': _("Enter email address send activation e-mail to. It must be valid e-mail you used to register on forums."), 'attrs': {'placeholder': _("Enter your e-mail address.")}})]
+               ),
+              (
+               None,
+               ['captcha_qa', 'recaptcha']
+               ),
+              ]
+
+    def clean_email(self):
+        try:
+            email = self.cleaned_data['email'].lower()
+            email_hash = hashlib.md5(email).hexdigest()
+            self.found_user = User.objects.get(email_hash=email_hash)
+        except User.DoesNotExist:
+            raise ValidationError(_("There is no user with such e-mail address."))
+        return email

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

@@ -0,0 +1,6 @@
+from django.conf.urls import patterns, url
+
+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"),
+)

+ 87 - 0
misago/apps/activation/views.py

@@ -0,0 +1,87 @@
+from django.template import RequestContext
+from django.utils.translation import ugettext as _
+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.models import Ban, User
+from misago.utils.views import redirect_message
+from misago.apps.activation.forms import UserSendActivationMailForm
+
+@block_crawlers
+@block_banned
+@block_authenticated
+@block_jammed
+def form(request):
+    message = None
+    if request.method == 'POST':
+        form = UserSendActivationMailForm(request.POST, request=request)
+        if form.is_valid():
+            user = form.found_user
+            user_ban = Ban.objects.check_ban(username=user.username, email=user.email)
+
+            if user_ban:
+                return error_banned(request, user, user_ban)
+
+            if user.activation == User.ACTIVATION_NONE:
+                return redirect_message(request, Message(_("%(username)s, your account is already active.") % {'username': user.username}), 'info')
+
+            if user.activation == User.ACTIVATION_ADMIN:
+                return redirect_message(request, Message(_("%(username)s, only board administrator can activate your account.") % {'username': user.username}), 'info')
+
+            user.email_user(
+                            request,
+                            'users/activation/resend',
+                            _("Account Activation"),
+                            )
+            return redirect_message(request, Message(_("%(username)s, e-mail containing new activation link has been sent to %(email)s.") % {'username': user.username, 'email': user.email}), 'success')
+        else:
+            message = Message(form.non_field_errors()[0], 'error')
+    else:
+        form = UserSendActivationMailForm(request=request)
+    return request.theme.render_to_response('resend_activation.html',
+                                            {
+                                             'message': message,
+                                             'form': FormLayout(form),
+                                            },
+                                            context_instance=RequestContext(request));
+
+
+@block_banned
+@block_authenticated
+@block_jammed
+def activate(request, username="", user="0", token=""):
+    user = int(user)
+
+    try:
+        user = User.objects.get(pk=user)
+        current_activation = user.activation
+
+        # Run checks
+        user_ban = Ban.objects.check_ban(username=user.username, email=user.email)
+        if user_ban:
+            return error_banned(request, user, user_ban)
+
+        if user.activation == User.ACTIVATION_NONE:
+            return redirect_message(request, Message(_("%(username)s, your account is already active.") % {'username': user.username}), 'info')
+
+        if user.activation == User.ACTIVATION_ADMIN:
+            return redirect_message(request, Message(_("%(username)s, only board administrator can activate your account.") % {'username': user.username}), 'info')
+
+        if not token or not user.token or user.token != token:
+            return redirect_message(request, Message(_("%(username)s, your activation link is invalid. Try again or request new activation e-mail.") % {'username': user.username}), 'error')
+
+        # Activate and sign in our member
+        user.activation = User.ACTIVATION_NONE
+        sign_user_in(request, user)
+
+        # Update monitor
+        User.objects.resync_monitor(request.monitor)
+
+        if current_activation == User.ACTIVATION_CREDENTIALS:
+            return redirect_message(request, Message(_("%(username)s, your account has been successfully reactivated after change of sign-in credentials.") % {'username': user.username}), 'success')
+        else:
+            return redirect_message(request, Message(_("%(username)s, your account has been successfully activated. Welcome aboard!") % {'username': user.username}), 'success')
+    except User.DoesNotExist:
+        return error404(request)

+ 51 - 0
misago/apps/alerts.py

@@ -0,0 +1,51 @@
+from django.template import RequestContext
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from misago.decorators import block_guest
+
+@block_guest
+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
+        if diff.days <= 0:
+            try:
+                alerts['today'].append(alert)
+            except KeyError:
+                alerts['today'] = [alert]
+        elif diff.days <= 1:
+            try:
+                alerts['yesterday'].append(alert)
+            except KeyError:
+                alerts['yesterday'] = [alert]
+        elif diff.days <= 7:
+            try:
+                alerts['week'].append(alert)
+            except KeyError:
+                alerts['week'] = [alert]
+        elif diff.days <= 30:
+            try:
+                alerts['month'].append(alert)
+            except KeyError:
+                alerts['month'] = [alert]
+        else:
+            try:
+                alerts['older'].append(alert)
+            except KeyError:
+                alerts['older'] = [alert]
+
+    new_alerts = request.user.alerts
+    request.user.alerts = 0
+    request.user.alerts_date = now
+    request.user.save(force_update=True)
+    return request.theme.render_to_response('alerts.html',
+                                            {
+                                             'new_alerts': new_alerts,
+                                             'alerts': alerts,
+                                             },
+                                            context_instance=RequestContext(request))

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

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

+ 22 - 0
misago/apps/newsfeed.py

@@ -0,0 +1,22 @@
+from django.template import RequestContext
+from misago.decorators import block_guest
+from misago.models import Post
+
+@block_guest
+def newsfeed(request):
+    follows = []
+    for user in request.user.follows.iterator():
+        follows.append(user.pk)
+    queryset = []
+    if follows:
+        queryset = Post.objects.filter(forum_id__in=request.acl.threads.get_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')
+        queryset = queryset[:18]
+    return request.theme.render_to_response('newsfeed.html',
+                                            {
+                                             'follows': follows,
+                                             'posts': queryset,
+                                             },
+                                            context_instance=RequestContext(request))

+ 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 Thread
+from misago.utils.pagination import make_pagination
+
+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));

+ 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 Thread
+from misago.utils.pagination import make_pagination
+
+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));

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


+ 37 - 0
misago/apps/profiles/decorators.py

@@ -0,0 +1,37 @@
+from functools import wraps
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+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):
+        def inner_decorator(request, user, username, *args, **kwargs):
+            request = request
+            user_pk = int(user)
+            user_slug = username
+            try:
+                user = User.objects.get(pk=user_pk)
+                if user.username_slug != user_slug:
+                    # Force crawlers to take notice of updated username
+                    return redirect(reverse(fallback, args=(user.username_slug, user.pk)), permanent=True)
+                return f(request, user, *args, **kwargs)
+            except User.DoesNotExist:
+                return error404(request)
+    
+        return wraps(f)(inner_decorator)
+    return outer_decorator
+
+
+def user_view(f):
+    def inner_decorator(request, user, *args, **kwargs):
+        request = request
+        user_pk = int(user)
+        try:
+            user = User.objects.get(pk=user_pk)
+            return f(request, user, *args, **kwargs)
+        except User.DoesNotExist:
+            return error404(request)
+
+    return wraps(f)(inner_decorator)

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


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

@@ -0,0 +1,4 @@
+from django.utils.translation import ugettext_lazy as _
+
+def register_profile_extension(request):
+    return (('user_details', _('Profile Details')),)

+ 14 - 0
misago/apps/profiles/details/urls.py

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

+ 10 - 0
misago/apps/profiles/details/views.py

@@ -0,0 +1,10 @@
+from misago.apps.profiles.decorators import profile_view
+from misago.apps.profiles.template import RequestContext
+
+@profile_view('user_details')
+def details(request, user):
+    return request.theme.render_to_response('profiles/details.html',
+                                            context_instance=RequestContext(request, {
+                                             'profile': user,
+                                             'tab': 'details',
+                                             }));

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


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

@@ -0,0 +1,4 @@
+from django.utils.translation import ugettext_lazy as _
+
+def register_profile_extension(request):
+    return (('user_followers', _('Followers')),)

+ 16 - 0
misago/apps/profiles/followers/urls.py

@@ -0,0 +1,16 @@
+from django.conf.urls import patterns, url
+
+def register_profile_urls(first=False):
+    urlpatterns = []
+    if first:
+        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.apps.profiles.followers.views',
+            url(r'^followers/$', 'followers', name="user_followers"),
+            url(r'^followers/(?P<page>\d+)/$', 'followers', name="user_followers"),
+        )
+    return urlpatterns

+ 18 - 0
misago/apps/profiles/followers/views.py

@@ -0,0 +1,18 @@
+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,
+                                             'tab': 'followers',
+                                             'items_total': count,
+                                             'items': queryset[pagination['start']:pagination['stop']],
+                                             'pagination': pagination,
+                                             }));

+ 0 - 0
misago/apps/profiles/follows/__init__.py


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

@@ -0,0 +1,4 @@
+from django.utils.translation import ugettext_lazy as _
+
+def register_profile_extension(request):
+    return (('user_follows', _('Follows')),)

+ 16 - 0
misago/apps/profiles/follows/urls.py

@@ -0,0 +1,16 @@
+from django.conf.urls import patterns, url
+
+def register_profile_urls(first=False):
+    urlpatterns = []
+    if first:
+        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.apps.profiles.follows.views',
+            url(r'^follows/$', 'follows', name="user_follows"),
+            url(r'^follows/(?P<page>\d+)/$', 'follows', name="user_follows"),
+        )
+    return urlpatterns

+ 18 - 0
misago/apps/profiles/follows/views.py

@@ -0,0 +1,18 @@
+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)
+    return request.theme.render_to_response('profiles/follows.html',
+                                            context_instance=RequestContext(request, {
+                                             'profile': user,
+                                             'tab': 'follows',
+                                             'items_total': count,
+                                             'items': queryset[pagination['start']:pagination['stop']],
+                                             'pagination': pagination,
+                                             }));

+ 5 - 0
misago/apps/profiles/forms.py

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

+ 0 - 0
misago/apps/profiles/posts/__init__.py


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

@@ -0,0 +1,4 @@
+from django.utils.translation import ugettext_lazy as _
+
+def register_profile_extension(request):
+    return (('user_posts', _('Posts')),)

+ 16 - 0
misago/apps/profiles/posts/urls.py

@@ -0,0 +1,16 @@
+from django.conf.urls import patterns, url
+
+def register_profile_urls(first=False):
+    urlpatterns = []
+    if first:
+        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.apps.profiles.posts.views',
+            url(r'^posts/$', 'posts', name="user_posts"),
+            url(r'^posts/(?P<page>\d+)/$', 'posts', name="user_posts"),
+        )
+    return urlpatterns

+ 17 - 0
misago/apps/profiles/posts/views.py

@@ -0,0 +1,17 @@
+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_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')
+    count = queryset.count()
+    pagination = make_pagination(page, count, 12)
+    return request.theme.render_to_response('profiles/posts.html',
+                                            context_instance=RequestContext(request, {
+                                             'profile': user,
+                                             'tab': 'posts',
+                                             'items_total': count,
+                                             'items': queryset[pagination['start']:pagination['stop']],
+                                             'pagination': pagination,
+                                             }));

+ 55 - 0
misago/apps/profiles/template.py

@@ -0,0 +1,55 @@
+from datetime import timedelta
+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.models import User
+
+def RequestContext(request, context=None):
+    if not context:
+        context = {}
+    context['fallback'] = request.path
+        
+    # Find out if we ignore or follow this user
+    context['follows'] = False
+    context['ignores'] = False
+    if request.user.is_authenticated() and request.user.pk != context['profile'].pk:
+        context['follows'] = request.user.is_following(context['profile'])
+        context['ignores'] = request.user.is_ignoring(context['profile'])
+        
+    # Find out if this user allows us to see his activity
+    if request.user.pk != context['profile'].pk:
+        if context['profile'].hide_activity == 2:
+            context['hidden'] = True
+        if context['profile'].hide_activity == 1:
+            context['hidden'] = context['profile'].is_following(request.user)
+    else:
+        context['hidden'] = False
+
+    # Find out if this user is online:
+    if request.user.pk != context['profile'].pk:
+        try:
+            context['online'] = context['profile'].sessions.filter(admin=False).filter(last__gt=(timezone.now() - timedelta(minutes=10))).order_by('-last')[0:1][0]
+        except IndexError:
+            context['online'] = False
+    else:
+        # Fake "right now" time
+        context['online'] = {'last': timezone.now()}
+
+    context['tabs'] = []
+    for extension in settings.PROFILE_EXTENSIONS:
+        profile_module = import_module(extension + '.profile')
+        try:
+            append_links = profile_module.register_profile_extension(request)
+            if append_links:
+                for link in append_links:
+                    link = list(link)
+                    token = link[0][link[0].find('_') + 1:]
+                    context['tabs'].append({
+                                            'route': link[0],
+                                            'active': context['tab'] == token,
+                                            'name': link[1],
+                                            })
+        except AttributeError:
+            pass
+    return DjangoRequestContext(request, context)

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


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

@@ -0,0 +1,4 @@
+from django.utils.translation import ugettext_lazy as _
+
+def register_profile_extension(request):
+    return (('user_threads', _('Threads')),)

+ 16 - 0
misago/apps/profiles/threads/urls.py

@@ -0,0 +1,16 @@
+from django.conf.urls import patterns, url
+
+def register_profile_urls(first=False):
+    urlpatterns = []
+    if first:
+        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.apps.profiles.threads.views',
+            url(r'^threads/$', 'threads', name="user_threads"),
+            url(r'^threads/(?P<page>\d+)/$', 'threads', name="user_threads"),
+        )
+    return urlpatterns

+ 18 - 0
misago/apps/profiles/threads/views.py

@@ -0,0 +1,18 @@
+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_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')
+    count = queryset.count()
+    pagination = make_pagination(page, count, 12)
+    
+    return request.theme.render_to_response('profiles/threads.html',
+                                            context_instance=RequestContext(request, {
+                                             'profile': user,
+                                             'tab': 'threads',
+                                             'items_total': count,
+                                             'items': queryset[pagination['start']:pagination['stop']],
+                                             'pagination': pagination,
+                                             }));

+ 25 - 0
misago/apps/profiles/urls.py

@@ -0,0 +1,25 @@
+from django.conf import settings
+from django.conf.urls import patterns, include, url
+from django.utils.importlib import import_module
+
+urlpatterns = patterns('misago.apps.profiles.views',
+    url(r'^$', 'list', name="users"),
+    url(r'^(?P<page>[0-9]+)/$', 'list', name="users"),
+)
+
+# Build extensions URLs
+iteration = 0
+for extension in settings.PROFILE_EXTENSIONS:
+    iteration += 1
+    profile_extension = import_module(extension + '.urls')
+    try:
+        urlpatterns += patterns('',
+            (r'^(?P<username>\w+)-(?P<user>\d+)/', include(profile_extension.register_profile_urls(iteration == 1))),
+        )
+    except AttributeError:
+        pass
+
+urlpatterns += patterns('misago.apps.profiles.views',
+    url(r'^(?P<rank_slug>(\w|-)+)/$', 'list', name="users"),
+    url(r'^(?P<rank_slug>(\w|-)+)/(?P<page>[0-9]+)/$', 'list', name="users"),
+)

+ 87 - 0
misago/apps/profiles/views.py

@@ -0,0 +1,87 @@
+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.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):
+    ranks = Rank.objects.filter(as_tab=1).order_by('order')
+
+    # Find active rank
+    default_rank = False
+    active_rank = None
+    if rank_slug:
+        for rank in ranks:
+            if rank.name_slug == rank_slug:
+                active_rank = rank
+        if not active_rank:
+            return error404(request)
+    elif ranks:
+        default_rank = True
+        active_rank = ranks[0]
+
+    # Empty Defaults
+    message = None
+    users = []
+    items_total = 0
+    pagination = None
+    in_search = False
+
+    # Users search?
+    if request.method == 'POST':
+        if not request.acl.users.can_search_users():
+            return error403(request)
+        in_search = True
+        active_rank = None
+        search_form = QuickFindUserForm(request.POST, request=request)
+        if search_form.is_valid():
+            # Direct hit?
+            username = search_form.cleaned_data['username']
+            try:
+                user = User.objects.get(username__iexact=username)
+                return redirect(reverse('user', args=(user.username_slug, user.pk)))
+            except User.DoesNotExist:
+                pass
+
+            # Looks like well have to find near match
+            if len(username) > 6:
+                username = username[0:-3]
+            elif len(username) > 5:
+                username = username[0:-2]
+            elif len(username) > 4:
+                username = username[0:-1]
+            username = slugify(username.strip())
+
+            # Go for rought match
+            if len(username) > 0:
+                users = User.objects.filter(username_slug__startswith=username).order_by('username_slug')[:10]
+        elif search_form.non_field_errors()[0] == 'form_contains_errors':
+            message = Message(_("To search users you have to enter username in search field."), 'error')
+        else:
+            message = Message(search_form.non_field_errors()[0], 'error')
+    else:
+        search_form = QuickFindUserForm(request=request)
+        if active_rank:
+            users = User.objects.filter(rank=active_rank)
+            items_total = users.count()
+            pagination = make_pagination(page, items_total, 4)
+            users = users.order_by('username_slug')[pagination['start']:pagination['stop']]
+
+    return request.theme.render_to_response('profiles/list.html',
+                                        {
+                                         'message': message,
+                                         'search_form': FormFields(search_form).fields,
+                                         'in_search': in_search,
+                                         'active_rank': active_rank,
+                                         'default_rank': default_rank,
+                                         'items_total': items_total,
+                                         'ranks': ranks,
+                                         'users': users,
+                                         'pagination': pagination,
+                                        },
+                                        context_instance=RequestContext(request));

+ 21 - 0
misago/apps/redirect.py

@@ -0,0 +1,21 @@
+from django.shortcuts import 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 redirect(forum.redirect)
+    except Forum.DoesNotExist:
+        return error404(request)

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


+ 79 - 0
misago/apps/register/forms.py

@@ -0,0 +1,79 @@
+from django import forms
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext_lazy as _
+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)
+    email = forms.EmailField(max_length=255)
+    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 = 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'))
+    repeats_errors = [{
+                       'different': _("Entered addresses do not match."), 
+                       },
+                      {
+                       'different': _("Entered passwords do not match."),
+                       }]
+      
+    def finalize_form(self):
+        self.layout = [
+                      (
+                       None,
+                       [('username', {'label': _('Username'), 'help_text': _("Your displayed username. Between %(min)s and %(max)s characters, only letters and digits are allowed.") % {'min': self.request.settings['username_length_min'], 'max': self.request.settings['username_length_max']},'attrs': {'placeholder': _("Enter your desired username")}})]
+                       ),
+                      (
+                       None,
+                       [('nested', [('email', {'label': _('E-mail address'), 'help_text': _("Working e-mail inbox is required to maintain control over your forum account."), 'attrs': {'placeholder': _("Enter your e-mail")}, 'width': 50}), ('email_rep', {'attrs': {'placeholder': _("Repeat your e-mail")}, 'width': 50})]), 
+                       ('nested', [('password', {'label': _('Password'), 'help_text': _("Password you will be using to sign in to your account. Make sure it's strong."), 'has_value': False, 'attrs': {'placeholder': _("Enter your password")}, 'width': 50}), ('password_rep', {'has_value': False, 'attrs': {'placeholder': _("Repeat your password")}, 'width': 50})])]
+                       ),
+                      (
+                       None,
+                       ['captcha_qa', 'recaptcha']
+                       ),
+                      (
+                       None,
+                       [('accept_tos', {'label': _("Forum Terms of Service"), 'widget': 'forumTos'})]
+                       ),
+                      ]
+        
+        if not self.request.settings['tos_url'] and not self.request.settings['tos_content']:
+            del self.fields['accept_tos']
+            del self.layout[3]
+        
+    def clean_username(self):
+        validate_username(self.cleaned_data['username'], self.request.settings)
+        new_user = User.objects.get_blank_user()
+        new_user.set_username(self.cleaned_data['username'])
+        try:
+            new_user.full_clean()
+        except ValidationError as e:
+            new_user.is_username_valid(e)
+        return self.cleaned_data['username']
+        
+    def clean_email(self):
+        new_user = User.objects.get_blank_user()
+        new_user.set_email(self.cleaned_data['email'])
+        try:
+            new_user.full_clean()
+        except ValidationError as e:
+            new_user.is_email_valid(e)
+        return self.cleaned_data['email']
+        
+    def clean_password(self):
+        validate_password(self.cleaned_data['password'], self.request.settings)
+        new_user = User.objects.get_blank_user()
+        new_user.set_password(self.cleaned_data['password'])
+        try:
+            new_user.full_clean()
+        except ValidationError as e:
+            new_user.is_password_valid(e)
+        return self.cleaned_data['password']

+ 84 - 0
misago/apps/register/views.py

@@ -0,0 +1,84 @@
+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.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.models import SignInAttempt, User
+from misago.utils.views import redirect_message
+from misago.apps.register.forms import UserRegisterForm
+
+@block_crawlers
+@block_banned
+@block_authenticated
+@block_jammed
+def form(request):
+    if request.settings['account_activation'] == 'block':
+       return redirect_message(request, Message(_("We are sorry but we don't allow new members registrations at this time.")), 'info')
+    
+    message = None
+    if request.method == 'POST':
+        form = UserRegisterForm(request.POST, request=request)
+        if form.is_valid():
+            need_activation = 0
+            if request.settings['account_activation'] == 'user':
+                need_activation = User.ACTIVATION_USER
+            if request.settings['account_activation'] == 'admin':
+                need_activation = User.ACTIVATION_ADMIN
+                
+            new_user = User.objects.create_user(
+                                                form.cleaned_data['username'],
+                                                form.cleaned_data['email'],
+                                                form.cleaned_data['password'],
+                                                ip=request.session.get_ip(request),
+                                                agent=request.META.get('HTTP_USER_AGENT'),
+                                                activation=need_activation,
+                                                request=request
+                                                )
+                        
+            if need_activation == User.ACTIVATION_NONE:
+                # Sign in user
+                sign_user_in(request, new_user)
+                request.messages.set_flash(Message(_("Welcome aboard, %(username)s! Your account has been registered successfully.") % {'username': new_user.username}), 'success')
+                
+            if need_activation == User.ACTIVATION_USER:
+                # Mail user activation e-mail
+                request.messages.set_flash(Message(_("%(username)s, your account has been registered, but you will have to activate it before you will be able to sign-in. We have sent you an e-mail with activation link.") % {'username': new_user.username}), 'info')
+                new_user.email_user(
+                                    request,
+                                    'users/activation/user',
+                                    _("Welcome aboard, %(username)s!") % {'username': new_user.username},
+                                    )
+                
+            if need_activation == User.ACTIVATION_ADMIN:
+                # Require admin activation
+                request.messages.set_flash(Message(_("%(username)s, Your account has been registered, but you won't be able to sign in until board administrator accepts it. We'll notify when this happens. Thank you for your patience!") % {'username': new_user.username}), 'info')
+                new_user.email_user(
+                                    request,
+                                    'users/activation/admin',
+                                    _("Welcome aboard, %(username)s!") % {'username': new_user.username},
+                                    {'password': form.cleaned_data['password']}
+                                    )
+            
+            User.objects.resync_monitor(request.monitor)
+            return redirect(reverse('index'))
+        else:
+            message = Message(form.non_field_errors()[0], 'error')
+            if request.settings['registrations_jams']:
+                SignInAttempt.objects.register_attempt(request.session.get_ip(request))
+            # Have we jammed our account?
+            if SignInAttempt.objects.is_jammed(request.settings, request.session.get_ip(request)):
+                request.jam.expires = timezone.now()
+                return redirect(reverse('register'))
+    else:
+        form = UserRegisterForm(request=request)
+    return request.theme.render_to_response('register.html',
+                                            {
+                                             'message': message,
+                                             'form': FormLayout(form),
+                                             'hide_signin': True, 
+                                            },
+                                            context_instance=RequestContext(request));

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


+ 32 - 0
misago/apps/resetpswd/forms.py

@@ -0,0 +1,32 @@
+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, QACaptchaField, ReCaptchaField
+from misago.models import User
+    
+class UserResetPasswordForm(Form):
+    email = forms.EmailField(max_length=255)
+    captcha_qa = QACaptchaField()
+    recaptcha = ReCaptchaField()
+    error_source = 'email'
+    
+    layout = [
+              (
+               None,
+               [('email', {'label': _("Your E-mail Address"), 'help_text': _("Enter email address password reset confirmation e-mail will be sent to. It must be valid e-mail you used to register on forums."), 'attrs': {'placeholder': _("Enter your e-mail address.")}})]
+               ),
+              (
+               None,
+               ['captcha_qa', 'recaptcha']
+               ),
+              ]
+    
+    def clean_email(self):
+        try:
+            email = self.cleaned_data['email'].lower()
+            email_hash = hashlib.md5(email).hexdigest()
+            self.found_user = User.objects.get(email_hash=email_hash)
+        except User.DoesNotExist:
+            raise ValidationError(_("There is no user with such e-mail address."))
+        return email

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

@@ -0,0 +1,6 @@
+from django.conf.urls import patterns, url
+
+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"),
+)

+ 89 - 0
misago/apps/resetpswd/views.py

@@ -0,0 +1,89 @@
+from django.template import RequestContext
+from django.utils.translation import ugettext as _
+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.models import Ban, 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
+@block_authenticated
+@block_jammed   
+def form(request):
+    message = None
+    
+    if request.method == 'POST':
+        form = UserResetPasswordForm(request.POST, request=request)
+        
+        if form.is_valid():
+            user = form.found_user
+            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 = random_string(12)
+            user.save(force_update=True)
+            user.email_user(
+                            request,
+                            'users/password/confirm',
+                            _("Confirm New Password Request")
+                            )
+            
+            return redirect_message(request, Message(_("%(username)s, new password request confirmation has been sent to %(email)s.") % {'username': user.username, 'email': user.email}), 'info')
+        else:
+            message = Message(form.non_field_errors()[0], 'error')
+    else:
+        form = UserResetPasswordForm(request=request)
+    return request.theme.render_to_response('reset_password.html',
+                                            {
+                                             'message': message,
+                                             'form': FormLayout(form),
+                                            },
+                                            context_instance=RequestContext(request));
+
+
+@block_banned
+@block_authenticated
+@block_jammed
+def reset(request, username="", user="0", token=""):
+    user = int(user)
+    try:
+        user = User.objects.get(pk=user)
+        user_ban = Ban.objects.check_ban(username=user.username, email=user.email)
+        
+        if user_ban:
+            return error_banned(request, user, user_ban)
+        
+        if 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')
+        
+        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 = random_string(6)
+        user.token = None
+        user.set_password(new_password)
+        user.save(force_update=True)
+        
+        # Logout signed in and kill remember me tokens
+        Session.objects.filter(user=user).update(user=None)
+        Token.objects.filter(user=user).delete()
+        
+        # Set flash and mail new password
+        user.email_user(
+                        request,
+                        'users/password/new',
+                        _("Your New Password"),
+                        {'password': new_password}
+                        )
+        
+        return redirect_message(request, Message(_("%(username)s, your password has been changed with new one that was sent to %(email)s.") % {'username': user.username, 'email': user.email}), 'success')
+    except User.DoesNotExist:
+        return error404(request)

+ 1 - 1
misago/apps/signin/views.py

@@ -61,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/apps/threads/__init__.py


+ 225 - 0
misago/apps/threads/forms.py

@@ -0,0 +1,225 @@
+from django import forms
+from django.conf import settings
+from django.utils.translation import ungettext, ugettext_lazy as _
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.forms import Form, ForumChoiceField
+from misago.models import Forum, Thread
+from misago.utils.strings import slugify
+from misago.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

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

@@ -0,0 +1,34 @@
+from django.conf.urls import patterns, url
+
+urlpatterns = patterns('misago.apps.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/$', 'PostingNewThreadView', name="thread_new"),
+    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/$', 'PostingNewReplyView', name="thread_reply"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/edit/$', 'PostingEditThreadView', name="thread_edit"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/edit/$', 'PostingEditReplyView', name="post_edit"),
+    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"),
+)

+ 8 - 0
misago/apps/threads/views/__init__.py

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

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

@@ -0,0 +1,14 @@
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from misago.utils.pagination 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))

+ 125 - 0
misago/apps/threads/views/changelog.py

@@ -0,0 +1,125 @@
+import difflib
+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.exceptions import ACLError403, ACLError404
+from misago.apps.errors import error403, error404
+from misago.markdown import post_markdown
+from misago.messages import Message
+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.threads.views.base import BaseView
+
+class ChangelogBaseView(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_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.forum = None
+        self.thread = None
+        self.post = None
+        try:
+            self.fetch_target(kwargs)
+            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)
+        except ACLError404 as e:
+            return error404(request, e.message)
+        return self.dispatch(request, **kwargs)
+
+
+class ChangelogView(ChangelogBaseView):
+    def dispatch(self, request, **kwargs):
+        return request.theme.render_to_response('threads/changelog.html',
+                                                {
+                                                 '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'])
+
+    def dispatch(self, request, **kwargs):
+        try:
+            next = self.post.change_set.filter(id__gt=self.change.pk)[:1][0]
+        except IndexError:
+            next = None
+        try:
+            prev = self.post.change_set.filter(id__lt=self.change.pk).order_by('-id')[:1][0]
+        except IndexError:
+            prev = None
+        self.forum.closed = self.proxy.closed
+        return request.theme.render_to_response('threads/changelog_diff.html',
+                                                {
+                                                 'forum': self.forum,
+                                                 'parents': self.parents,
+                                                 'thread': self.thread,
+                                                 'post': self.post,
+                                                 'change': self.change,
+                                                 'next': next,
+                                                 'prev': prev,
+                                                 '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'])
+        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}))
+
+        if self.change.thread_name_old and self.change.thread_name_old != self.thread.name:
+            self.thread.name = self.change.thread_name_old
+            self.thread.slug = slugify(self.change.thread_name_old)
+            self.thread.save(force_update=True)
+
+            if self.forum.last_thread_id == self.thread.pk:
+                self.forum.last_thread_name = self.change.thread_name_old
+                self.forum.last_thread_slug = slugify(self.change.thread_name_old)
+                self.forum.save(force_update=True)
+
+        if self.change.post_content != self.post.post:
+            self.post.post = self.change.post_content
+            md, self.post.post_preparsed = post_markdown(request, self.change.post_content)
+            self.post.save(force_update=True)
+
+        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))

+ 105 - 0
misago/apps/threads/views/delete.py

@@ -0,0 +1,105 @@
+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.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.utils.pagination import make_pagination
+from misago.apps.threads.views.base import BaseView
+
+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)

+ 40 - 0
misago/apps/threads/views/details.py

@@ -0,0 +1,40 @@
+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.threads.views.base import BaseView
+
+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))

+ 239 - 0
misago/apps/threads/views/jumps.py

@@ -0,0 +1,239 @@
+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.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.models import Forum, Thread, Post, Karma, WatchedThread
+from misago.readstrackers import ThreadsTracker
+from misago.utils.pagination import make_pagination
+from misago.apps.threads.views.base import BaseView
+
+class JumpView(BaseView):
+    def fetch_thread(self, thread):
+        self.thread = Thread.objects.get(pk=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)
+
+    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)
+
+    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
+        try:
+            self.fetch_thread(thread)
+            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)
+        except ACLError404 as e:
+            return error404(request, e.message)
+
+
+class LastReplyView(JumpView):
+    def make_jump(self):
+        return self.redirect(self.thread.post_set.order_by('-id')[:1][0])
+
+
+class FindReplyView(JumpView):
+    def make_jump(self):
+        return self.redirect(self.post)
+
+
+class NewReplyView(JumpView):
+    def make_jump(self):
+        if not self.request.user.is_authenticated():
+            return self.redirect(self.thread.post_set.order_by('-id')[:1][0])
+        tracker = ThreadsTracker(self.request, self.forum)
+        read_date = tracker.get_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])
+
+
+class FirstModeratedView(JumpView):
+    def make_jump(self):
+        if not self.request.acl.threads.can_approve(self.forum):
+            raise ACLError404()
+        try:
+            return self.redirect(
+                self.thread.post_set.get(moderated=True))
+        except Post.DoesNotExist:
+            return error404(self.request)
+
+
+class FirstReportedView(JumpView):
+    def make_jump(self):
+        if not self.request.acl.threads.can_mod_posts(self.forum):
+            raise ACLError404()
+        try:
+            return self.redirect(
+                self.thread.post_set.get(reported=True))
+        except Post.DoesNotExist:
+            return error404(self.request)
+
+
+class ShowHiddenRepliesView(JumpView):
+    def make_jump(self):
+        @block_guest
+        @check_csrf
+        def view(request):
+            ignored_exclusions = request.session.get('unignore_threads', [])
+            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 view(self.request)
+
+
+class WatchThreadView(JumpView):
+    def get_retreat(self):
+        return redirect(self.request.POST.get('retreat', reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug})))
+
+    def update_watcher(self, request, watcher):
+        request.messages.set_flash(Message(_('This thread has been added to your watched threads list.')), 'success', 'threads')
+
+    def make_jump(self):
+        @block_guest
+        @check_csrf
+        def view(request):
+            try:
+                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
+                watcher.last_read = timezone.now()
+            self.update_watcher(request, watcher)
+            if watcher.pk:
+                watcher.save(force_update=True)
+            else:
+                watcher.save(force_insert=True)
+            return self.get_retreat()
+        return view(self.request)
+
+
+class WatchEmailThreadView(WatchThreadView):
+    def update_watcher(self, request, watcher):
+        watcher.email = True
+        if watcher.pk:
+            request.messages.set_flash(Message(_('You will now receive e-mail with notification when somebody replies to this thread.')), 'success', 'threads')
+        else:
+            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):
+    def update_watcher(self, request, watcher):
+        watcher.deleted = True
+        watcher.delete()
+        if watcher.email:
+            request.messages.set_flash(Message(_('This thread has been removed from your watched threads list. You will no longer receive e-mails with notifications when somebody replies to it.')), 'success', 'threads')
+        else:
+            request.messages.set_flash(Message(_('This thread has been removed from your watched threads list.')), 'success', 'threads')
+
+
+class UnwatchEmailThreadView(WatchThreadView):
+    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):        
+    def make_jump(self):
+        @block_guest
+        @check_csrf
+        def view(request):
+            if self.post.user_id == request.user.id:
+                return error404(request)
+            self.check_acl(request)
+            try:
+                vote = Karma.objects.get(user=request.user, post=self.post)
+                if self.thread.start_post_id == self.post.pk:
+                    if vote.score > 0:
+                        self.thread.upvotes -= 1
+                    else:
+                        self.thread.downvotes -= 1
+                if vote.score > 0:
+                    self.post.upvotes -= 1
+                    request.user.karma_given_p -= 1
+                    if self.post.user_id:
+                        self.post.user.karma_p -= 1
+                else:
+                    self.post.downvotes -= 1
+                    request.user.karma_given_n -= 1
+                    if self.post.user_id:
+                        self.post.user.karma_n -= 1
+            except Karma.DoesNotExist:
+                vote = Karma()
+            vote.forum = self.forum
+            vote.thread = self.thread
+            vote.post = self.post
+            vote.user = request.user
+            vote.user_name = request.user.username
+            vote.user_slug = request.user.username_slug
+            vote.date = timezone.now()
+            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:
+                vote.save(force_insert=True)
+            if self.thread.start_post_id == self.post.pk:
+                if vote.score > 0:
+                    self.thread.upvotes += 1
+                else:
+                    self.thread.downvotes += 1
+                self.thread.save(force_update=True)
+            if vote.score > 0:
+                self.post.upvotes += 1
+                request.user.karma_given_p += 1
+                if self.post.user_id:
+                    self.post.user.karma_p += 1
+                    self.post.user.score += request.settings['score_reward_karma_positive']
+            else:
+                self.post.downvotes += 1
+                request.user.karma_given_n += 1
+                if self.post.user_id:
+                    self.post.user.karma_n += 1
+                    self.post.user.score -= request.settings['score_reward_karma_negative']
+            self.post.save(force_update=True)
+            request.user.save(force_update=True)
+            if self.post.user_id:
+                self.post.user.save(force_update=True)
+            return self.redirect(self.post)
+        return view(self.request)
+    
+    def check_acl(self, request):
+        request.acl.threads.allow_post_upvote(self.forum)
+    
+    def make_vote(self, request, vote):
+        vote.score = 1
+
+
+class DownvotePostView(UpvotePostView):
+    def check_acl(self, request):
+        request.acl.threads.allow_post_downvote(self.forum)
+    
+    def make_vote(self, request, vote):
+        vote.score = -1

+ 42 - 0
misago/apps/threads/views/karmas.py

@@ -0,0 +1,42 @@
+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.threads.views.base import BaseView
+
+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))

+ 392 - 0
misago/apps/threads/views/list.py

@@ -0,0 +1,392 @@
+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.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
+from misago.readstrackers import ForumsTracker, ThreadsTracker
+from misago.utils.strings import slugify
+from misago.utils.pagination import make_pagination
+from misago.apps.threads.forms import MoveThreadsForm, MergeThreadsForm
+from misago.apps.threads.views.base import BaseView
+
+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)
+
+    def action_accept(self, ids):
+        accepted = 0
+        last_posts = []
+        users = []
+        for thread in self.threads:
+            if thread.pk in ids and thread.moderated:
+                accepted += 1
+                # Sync thread and post
+                thread.moderated = False
+                thread.replies_moderated -= 1
+                thread.save(force_update=True)
+                thread.start_post.moderated = False
+                thread.start_post.save(force_update=True)
+                thread.last_post.set_checkpoint(self.request, 'accepted')
+                last_posts.append(thread.last_post.pk)
+                # Sync user
+                if thread.last_post.user:
+                    thread.start_post.user.threads += 1
+                    thread.start_post.user.posts += 1
+                    users.append(thread.start_post.user)
+        if accepted:
+            Post.objects.filter(id__in=last_posts).update(checkpoints=True)
+            self.request.monitor['threads'] = int(self.request.monitor['threads']) + accepted
+            self.request.monitor['posts'] = int(self.request.monitor['posts']) + accepted
+            self.forum.sync()
+            self.forum.save(force_update=True)
+            for user in users:
+                user.save(force_update=True)
+            self.request.messages.set_flash(Message(_('Selected threads have been marked as reviewed and made visible to other members.')), 'success', 'threads')
+
+    def action_annouce(self, ids):
+        acl = self.request.acl.threads.get_role(self.forum)
+        annouced = []
+        for thread in self.threads:
+            if thread.pk in ids and thread.weight < 2:
+                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')
+
+    def action_sticky(self, ids):
+        acl = self.request.acl.threads.get_role(self.forum)
+        sticky = []
+        for thread in self.threads:
+            if thread.pk in ids and thread.weight != 1 and (acl['can_pin_threads'] == 2 or thread.weight < 2):
+                sticky.append(thread.pk)
+        if sticky:
+            Thread.objects.filter(id__in=sticky).update(weight=1)
+            self.request.messages.set_flash(Message(_('Selected threads have been sticked to the top of list.')), 'success', 'threads')
+
+    def action_normal(self, ids):
+        normalised = []
+        for thread in self.threads:
+            if thread.pk in ids and thread.weight > 0:
+                normalised.append(thread.pk)
+        if normalised:
+            Thread.objects.filter(id__in=normalised).update(weight=0)
+            self.request.messages.set_flash(Message(_('Selected threads weight has been removed.')), 'success', 'threads')
+
+    def action_move(self, ids):
+        threads = []
+        for thread in self.threads:
+            if thread.pk in ids:
+                threads.append(thread)
+        if self.request.POST.get('origin') == 'move_form':
+            form = MoveThreadsForm(self.request.POST, request=self.request, forum=self.forum)
+            if form.is_valid():
+                new_forum = form.cleaned_data['new_forum']
+                for thread in threads:
+                    thread.move_to(new_forum)
+                    thread.save(force_update=True)
+                new_forum.sync()
+                new_forum.save(force_update=True)
+                self.forum.sync()
+                self.forum.save(force_update=True)
+                self.request.messages.set_flash(Message(_('Selected threads have been moved to "%(forum)s".') % {'forum': new_forum.name}), 'success', 'threads')
+                return None
+            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',
+                                                     {
+                                                      'message': self.message,
+                                                      'forum': self.forum,
+                                                      'parents': self.parents,
+                                                      'threads': threads,
+                                                      'form': FormLayout(form),
+                                                      },
+                                                     context_instance=RequestContext(self.request));
+
+    def action_merge(self, ids):
+        if len(ids) < 2:
+            raise ValidationError(_("You have to pick two or more threads to merge."))
+        threads = []
+        for thread in self.threads:
+            if thread.pk in ids:
+                threads.append(thread)
+        if self.request.POST.get('origin') == 'merge_form':
+            form = MergeThreadsForm(self.request.POST, request=self.request, threads=threads)
+            if form.is_valid():
+                new_thread = Thread.objects.create(
+                                                   forum=form.cleaned_data['new_forum'],
+                                                   name=form.cleaned_data['thread_name'],
+                                                   slug=slugify(form.cleaned_data['thread_name']),
+                                                   start=timezone.now(),
+                                                   last=timezone.now()
+                                                   )
+                last_merge = 0
+                last_thread = None
+                merged = []
+                for i in range(0, len(threads)):
+                    thread = form.merge_order[i]
+                    merged.append(thread.pk)
+                    if last_thread and last_thread.last > thread.start:
+                        last_merge += thread.merges + 1
+                    thread.merge_with(new_thread, last_merge=last_merge)
+                    last_thread = thread
+                Thread.objects.filter(id__in=merged).delete()
+                new_thread.sync()
+                new_thread.save(force_update=True)
+                self.forum.sync()
+                self.forum.save(force_update=True)
+                if form.cleaned_data['new_forum'].pk != self.forum.pk:
+                    form.cleaned_data['new_forum'].sync()
+                    form.cleaned_data['new_forum'].save(force_update=True)
+                self.request.messages.set_flash(Message(_('Selected threads have been merged into new one.')), 'success', 'threads')
+                return None
+            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',
+                                                     {
+                                                      'message': self.message,
+                                                      'forum': self.forum,
+                                                      'parents': self.parents,
+                                                      'threads': threads,
+                                                      'form': FormLayout(form),
+                                                      },
+                                                     context_instance=RequestContext(self.request));
+
+    def action_open(self, ids):
+        opened = []
+        last_posts = []
+        for thread in self.threads:
+            if thread.pk in ids and thread.closed:
+                opened.append(thread.pk)
+                thread.last_post.set_checkpoint(self.request, 'opened')
+                last_posts.append(thread.last_post.pk)
+        if opened:
+            Post.objects.filter(id__in=last_posts).update(checkpoints=True)
+            Thread.objects.filter(id__in=opened).update(closed=False)
+            self.request.messages.set_flash(Message(_('Selected threads have been opened.')), 'success', 'threads')
+
+    def action_close(self, ids):
+        closed = []
+        last_posts = []
+        for thread in self.threads:
+            if thread.pk in ids and not thread.closed:
+                closed.append(thread.pk)
+                thread.last_post.set_checkpoint(self.request, 'closed')
+                last_posts.append(thread.last_post.pk)
+        if closed:
+            Post.objects.filter(id__in=last_posts).update(checkpoints=True)
+            Thread.objects.filter(id__in=closed).update(closed=True)
+            self.request.messages.set_flash(Message(_('Selected threads have been closed.')), 'success', 'threads')
+
+    def action_undelete(self, ids):
+        undeleted = []
+        last_posts = []
+        posts = 0
+        for thread in self.threads:
+            if thread.pk in ids and thread.deleted:
+                undeleted.append(thread.pk)
+                posts += thread.replies + 1
+                thread.start_post.deleted = False
+                thread.start_post.save(force_update=True)
+                thread.last_post.set_checkpoint(self.request, 'undeleted')
+        if undeleted:
+            self.request.monitor['threads'] = int(self.request.monitor['threads']) + len(undeleted)
+            self.request.monitor['posts'] = int(self.request.monitor['posts']) + posts
+            self.forum.sync()
+            self.forum.save(force_update=True)
+            Post.objects.filter(id__in=last_posts).update(checkpoints=True)
+            Thread.objects.filter(id__in=undeleted).update(deleted=False)
+            self.request.messages.set_flash(Message(_('Selected threads have been undeleted.')), 'success', 'threads')
+
+    def action_soft(self, ids):
+        deleted = []
+        last_posts = []
+        posts = 0
+        for thread in self.threads:
+            if thread.pk in ids and not thread.deleted:
+                deleted.append(thread.pk)
+                posts += thread.replies + 1
+                thread.start_post.deleted = True
+                thread.start_post.save(force_update=True)
+                thread.last_post.set_checkpoint(self.request, 'deleted')
+                last_posts.append(thread.last_post.pk)
+        if deleted:
+            self.request.monitor['threads'] = int(self.request.monitor['threads']) - len(deleted)
+            self.request.monitor['posts'] = int(self.request.monitor['posts']) - posts
+            self.forum.sync()
+            self.forum.save(force_update=True)
+            Post.objects.filter(id__in=last_posts).update(checkpoints=True)
+            Thread.objects.filter(id__in=deleted).update(deleted=True)
+            self.request.messages.set_flash(Message(_('Selected threads have been softly deleted.')), 'success', 'threads')
+
+    def action_hard(self, ids):
+        deleted = []
+        posts = 0
+        for thread in self.threads:
+            if thread.pk in ids:
+                deleted.append(thread.pk)
+                posts += thread.replies + 1
+                thread.delete()
+        if deleted:
+            self.request.monitor['threads'] = int(self.request.monitor['threads']) - len(deleted)
+            self.request.monitor['posts'] = int(self.request.monitor['posts']) - posts
+            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));

+ 434 - 0
misago/apps/threads/views/posting.py

@@ -0,0 +1,434 @@
+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.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.datesformats import date
+from misago.utils.strings import slugify
+from misago.utils.pagination import make_pagination
+from misago.utils.translation import ugettext_lazy
+from misago.apps.threads.forms import PostForm
+from misago.apps.threads.views.base import BaseView
+
+class PostingBaseView(BaseView):
+    def __call__(self, request, **kwargs):
+        self.request = request
+
+        # Empty context attributes
+        self.forum = None
+        self.thread = None
+        self.quote = None
+        self.post = None
+
+        # Let inheriting class  set context
+        self.set_context()
+
+        # And set forum parents for render
+        self.parents = Forum.objects.forum_parents(self.forum.pk, True)
+
+        # Create form instance
+        
+
+    def set_context(self):
+        raise NotImplementedError(u"\"set_context\" method should be implemented in inheriting objects.")
+
+
+class PostingNewThreadView(PostingBaseView):
+    pass
+
+
+class PostingEditThreadView(PostingBaseView):
+    pass
+
+
+class PostingNewReplyView(PostingBaseView):
+    pass
+
+
+class PostingEditReplyView(PostingBaseView):
+    pass
+
+
+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:
+                    WatchedThread.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 = WatchedThread.objects.get(user=request.user, thread=self.thread)
+                    except WatchedThread.DoesNotExist:
+                        WatchedThread.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));

+ 565 - 0
misago/apps/threads/views/thread.py

@@ -0,0 +1,565 @@
+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.exceptions import ACLError403, ACLError404
+from misago.apps.errors import error403, error404
+from misago.forms import Form, FormLayout, FormFields
+from misago.markdown import post_markdown
+from misago.messages import Message
+from misago.models import Forum, Thread, Post, Karma, Change, Checkpoint, WatchedThread
+from misago.readstrackers import ThreadsTracker
+from misago.utils.strings import slugify
+from misago.utils.pagination import make_pagination
+from misago.apps.threads.forms import MoveThreadsForm, SplitThreadForm, MovePostsForm, QuickReplyForm
+from misago.apps.threads.views.base import BaseView
+
+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 = WatchedThread.objects.get(user=self.request.user, thread=self.thread)
+            except WatchedThread.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));

+ 8 - 0
misago/apps/tos.py

@@ -0,0 +1,8 @@
+from django.template import RequestContext
+from misago.apps.errors import error404
+
+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',
+                                            context_instance=RequestContext(request));

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


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


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

@@ -0,0 +1,32 @@
+from PIL import Image
+from django import forms
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext_lazy as _
+from misago.forms import Form
+
+class UploadAvatarForm(Form):
+    avatar_upload = forms.ImageField(error_messages={'invalid_image': _("Uploaded file is not correct image.")})
+    error_source = 'avatar_upload'
+
+    layout = [
+              [
+               None,
+               [
+                ('avatar_upload', {'label': _("Upload Image File"), 'help_text': _("Select image file on your computer you wish to use as forum avatar. You will be able to crop image after upload. Animations will be stripped.")}),
+                ],
+               ],
+              ]
+
+    def clean_avatar_upload(self):
+        image = self.cleaned_data.get('avatar_upload', False)
+        if image:
+            if image._size > self.request.settings.upload_limit * 1024:
+                if self.request.settings.upload_limit > 1024:
+                    limit = '%s Mb' % "{:10.2f}".format(float(self.request.settings.upload_limit / 1024.0))
+                else:
+                    limit = '%s Kb' % self.request.settings.upload_limit
+                raise ValidationError(_("Avatar image cannot be larger than %(limit)s.") % {'limit': limit})
+        else:
+            raise ValidationError(_("Couldn't read uploaded image"))
+        return image

+ 21 - 0
misago/apps/usercp/avatar/urls.py

@@ -0,0 +1,21 @@
+from django.conf.urls import patterns, url
+
+def register_usercp_urls(first=False):
+    urlpatterns = []
+    if first:
+        urlpatterns += patterns('misago.apps.usercp.avatar.views',
+            url(r'^$', 'avatar', name="usercp"),
+            url(r'^$', 'avatar', name="usercp_avatar"),
+        )
+    else:
+        urlpatterns += patterns('misago.apps.usercp.avatar.views',
+            url(r'^avatar/$', 'avatar', name="usercp_avatar"),
+        )
+    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}),
+        url(r'^avatar/crop/$', 'crop', name="usercp_avatar_crop"),
+        url(r'^avatar/gravatar/$', 'gravatar', name="usercp_avatar_gravatar"),
+    )
+    return urlpatterns

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

@@ -0,0 +1,4 @@
+from django.utils.translation import ugettext_lazy as _
+
+def register_usercp_extension(request):
+    return (('usercp_avatar', _('Change Avatar')),)

+ 228 - 0
misago/apps/usercp/avatar/views.py

@@ -0,0 +1,228 @@
+from path import path
+from PIL import Image
+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.translation import ugettext as _
+from misago.apps.errors import error404
+from misago.decorators import block_guest
+from misago.forms import FormLayout
+from misago.messages import Message
+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):
+        request = args[0]
+        if request.user.avatar_ban:
+            return request.theme.render_to_response('usercp/avatar_banned.html',
+                                                    context_instance=RequestContext(request, {
+                                                     'tab': 'avatar',
+                                                     }));
+        return f(*args, **kwargs)
+    return decorator
+
+
+@block_guest
+@avatar_view
+def avatar(request):
+    message = request.messages.get_message('usercp_avatar')
+    return request.theme.render_to_response('usercp/avatar.html',
+                                            context_instance=RequestContext(request, {
+                                             'message': message,
+                                             'tab': 'avatar',
+                                             }));
+
+
+@block_guest
+@avatar_view
+def gravatar(request):
+    if not 'gravatar' in request.settings.avatars_types:
+        return error404(request)
+    if request.user.avatar_type != 'gravatar':
+        if request.csrf.request_secure(request):
+            request.user.delete_avatar()
+            request.user.avatar_type = 'gravatar'
+            request.user.save(force_update=True)
+            request.messages.set_flash(Message(_("Your avatar has been changed to Gravatar.")), 'success', 'usercp_avatar')
+        else:
+            request.messages.set_flash(Message(_("Request authorisation is invalid.")), 'error', 'usercp_avatar')
+    return redirect(reverse('usercp_avatar'))
+
+
+@block_guest
+@avatar_view
+def gallery(request):
+    if not 'gallery' in request.settings.avatars_types:
+        return error404(request)
+
+    allowed_avatars = []
+    galleries = []
+    for directory in path(settings.STATICFILES_DIRS[0]).joinpath('avatars').dirs():
+        if directory[-7:] != '_locked' and directory[-8:] != '_default':
+            gallery = {'name': directory[-7:], 'avatars': []}
+            avatars = directory.files('*.gif')
+            avatars += directory.files('*.jpg')
+            avatars += directory.files('*.jpeg')
+            avatars += directory.files('*.png')
+            for item in avatars:
+                gallery['avatars'].append('/'.join(path(item).splitall()[-2:]))
+            galleries.append(gallery)
+            allowed_avatars += gallery['avatars']
+
+    if not allowed_avatars:
+        request.messages.set_flash(Message(_("No avatars are avaiable.")), 'info', 'usercp_avatar')
+        return redirect(reverse('usercp_avatar'))
+
+    message = request.messages.get_message('usercp_avatar')
+    if request.method == 'POST':
+        if request.csrf.request_secure(request):
+            new_avatar = request.POST.get('avatar_image')
+            if new_avatar in allowed_avatars:
+                request.user.delete_avatar()
+                request.user.avatar_type = 'gallery'
+                request.user.avatar_image = new_avatar
+                request.user.save(force_update=True)
+                request.messages.set_flash(Message(_("Your avatar has been changed to one from gallery.")), 'success', 'usercp_avatar')
+                return redirect(reverse('usercp_avatar'))
+            message = Message(_("Selected Avatar is incorrect."), 'error')
+        else:
+            message = Message(_("Request authorisation is invalid."), 'error')
+
+    return request.theme.render_to_response('usercp/avatar_gallery.html',
+                                            context_instance=RequestContext(request, {
+                                             'message': message,
+                                             'galleries': galleries,
+                                             'tab': 'avatar',
+                                             }));
+
+
+@block_guest
+@avatar_view
+def upload(request):
+    if not 'upload' in request.settings.avatars_types:
+        return error404(request)
+    message = request.messages.get_message('usercp_avatar')
+    if request.method == 'POST':
+        form = UploadAvatarForm(request.POST, request.FILES, request=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, random_string(8), image_extension)
+            image_path = settings.MEDIA_ROOT + 'avatars/' + image_name
+            request.user.avatar_temp = image_name
+
+            with open(image_path, 'wb+') as destination:
+                for chunk in image.chunks():
+                    destination.write(chunk)
+            request.user.save()
+            try:
+                image = Image.open(image_path)
+                if not image.format in ['GIF', 'PNG', 'JPEG']:
+                    raise ValidationError()
+                image.seek(0)
+                image.save(image_path)
+                if request.POST.get('js_check'):
+                    return redirect(reverse('usercp_avatar_upload_crop'))
+                # Redirect to crop page didnt happen, handle avatar with old school hollywood way
+                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, 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)
+                # Update user model one more time
+                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, random_string(8), image_extension)
+                source.save(image_path + request.user.avatar_original)
+                request.user.delete_avatar_temp()
+                request.user.avatar_image = image_name
+                request.user.save(force_update=True)
+                # Set message and adios!
+                request.messages.set_flash(Message(_("Your avatar has changed.")), 'success', 'usercp_avatar')
+                return redirect(reverse('usercp_avatar'))
+            except ValidationError:
+                request.user.delete_avatar()
+                request.user.default_avatar(request.settings)
+                message = Message(_("Only gif, jpeg and png files are allowed for member avatars."), 'error')
+        else:
+            message = Message(form.non_field_errors()[0], 'error')
+    else:
+        form = UploadAvatarForm(request=request)
+
+    return request.theme.render_to_response('usercp/avatar_upload.html',
+                                            context_instance=RequestContext(request, {
+                                             'message': message,
+                                             'form': FormLayout(form),
+                                             'tab': 'avatar',
+                                             }));
+
+
+@block_guest
+@avatar_view
+def crop(request, upload=False):
+    if upload and (not request.user.avatar_temp or not 'upload' in request.settings.avatars_types):
+        return error404(request)
+
+    if not upload and request.user.avatar_type != 'upload':
+        request.messages.set_flash(Message(_("Crop Avatar option is avaiable only when you use uploaded image as your avatar.")), 'error', 'usercp_avatar')
+        return redirect(reverse('usercp_avatar'))
+
+    message = request.messages.get_message('usercp_avatar')
+    if request.method == 'POST':
+        if request.csrf.request_secure(request):
+            try:
+                image_path = settings.MEDIA_ROOT + 'avatars/'
+                if upload:
+                    source = Image.open(image_path + request.user.avatar_temp)
+                else:
+                    source = Image.open(image_path + request.user.avatar_original)
+                width, height = source.size
+
+                aspect = float(width) / float(request.POST['crop_b'])
+                crop_x = int(aspect * float(request.POST['crop_x']))
+                crop_y = int(aspect * float(request.POST['crop_y']))
+                crop_w = int(aspect * float(request.POST['crop_w']))
+                crop = source.crop((crop_x, crop_y, crop_x + crop_w, crop_y + crop_w))
+
+                if upload:
+                    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, 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)
+
+                request.user.delete_avatar_image()
+                if upload:
+                    request.user.delete_avatar_original()
+                    request.user.avatar_type = 'upload'
+                    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
+                request.user.save(force_update=True)
+                request.messages.set_flash(Message(_("Your avatar has been cropped.")), 'success', 'usercp_avatar')
+                return redirect(reverse('usercp_avatar'))
+            except Exception:
+                message = Message(_("Form contains errors."), 'error')
+        else:
+            message = Message(_("Request authorisation is invalid."), 'error')
+
+
+    return request.theme.render_to_response('usercp/avatar_crop.html',
+                                            context_instance=RequestContext(request, {
+                                             'message': message,
+                                             'after_upload': upload,
+                                             'avatar_size': settings.AVATAR_SIZES[0],
+                                             'source': 'avatars/%s' % (request.user.avatar_temp if upload else request.user.avatar_original),
+                                             'tab': 'avatar',
+                                             }));

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


+ 52 - 0
misago/apps/usercp/credentials/forms.py

@@ -0,0 +1,52 @@
+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.models import User
+from misago.validators import validate_password, validate_email
+
+class CredentialsChangeForm(Form):
+    new_email = forms.EmailField(max_length=255, required=False)
+    new_password = forms.CharField(max_length=255, widget=forms.PasswordInput, required=False)
+    current_password = forms.CharField(max_length=255, widget=forms.PasswordInput)
+
+    layout = [
+              (
+               None,
+               [
+                ('new_email', {'label': _('New E-mail'), 'help_text': _("Enter new e-mail address or leave this field empty if you want only to change your password.")}),
+                ('new_password', {'label': _('New Password'), 'help_text': _("Enter new password or leave this empty if you only want to change your e-mail address.")}),
+                ('current_password', {'label': _('Current Password'), 'help_text': _("Confirm changes by entering new password.")})
+                ]
+               ),
+              ]
+
+    def clean_new_email(self):
+        if self.cleaned_data['new_email']:
+            new_hash = hashlib.md5(self.cleaned_data['new_email'].lower()).hexdigest()
+            if new_hash == self.request.user.email_hash:
+                raise ValidationError(_("New e-mail is same as your current e-mail."))
+            try:
+                User.objects.get(email_hash=new_hash)
+                raise ValidationError(_("New e-mail address is already in use by other member."))
+            except User.DoesNotExist:
+                pass
+            validate_email(self.cleaned_data['new_email'])
+        return self.cleaned_data['new_email'].lower()
+
+    def clean_new_password(self):
+        if self.cleaned_data['new_password']:
+            validate_password(self.cleaned_data['new_password'])
+        return self.cleaned_data['new_password']
+
+    def clean_current_password(self):
+        if not self.request.user.check_password(self.cleaned_data['current_password']):
+            raise ValidationError(_("You have entered wrong password."))
+        return ''
+
+    def clean(self):
+        cleaned_data = super(CredentialsChangeForm, self).clean()
+        if not cleaned_data['new_email'] and not cleaned_data['new_password']:
+            raise ValidationError(_("You have to enter either new e-mail address or new password."))
+        return cleaned_data

+ 19 - 0
misago/apps/usercp/credentials/urls.py

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

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

@@ -0,0 +1,4 @@
+from django.utils.translation import ugettext_lazy as _
+
+def register_usercp_extension(request):
+    return (('usercp_credentials', _('Change E-mail or Password')),)

+ 72 - 0
misago/apps/usercp/credentials/views.py

@@ -0,0 +1,72 @@
+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.apps.errors import error404
+from misago.decorators import block_guest
+from misago.forms import FormLayout
+from misago.messages import Message
+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):
+    message = request.messages.get_message('usercp_credentials')
+    if request.method == 'POST':
+        form = CredentialsChangeForm(request.POST, request=request)
+        if form.is_valid():
+            token = random_string(12)
+            request.user.email_user(
+                                    request,
+                                    'users/new_credentials',
+                                    _("Activate new Sign-In Credentials"),
+                                    {'token': token}
+                                    )
+            request.session['new_credentials'] = {
+                                                  'token': token,
+                                                  'email_hash': request.user.email_hash,
+                                                  'new_email': form.cleaned_data['new_email'],
+                                                  'new_password': form.cleaned_data['new_password'],
+                                                  }
+            if form.cleaned_data['new_email']:
+                request.user.email = form.cleaned_data['new_email']
+                request.messages.set_flash(Message(_("We have sent e-mail message to your new e-mail address with link you have to click to confirm change of your sign-in credentials. This link will be valid only for duration of this session, do not sign out until you confirm change!")), 'success', 'usercp_credentials')
+            else:
+                request.messages.set_flash(Message(_("We have sent e-mail message to your e-mail address with link you have to click to confirm change of your sign-in credentials. This link will be valid only for duration of this session, do not sign out until you confirm change!")), 'success', 'usercp_credentials')
+            return redirect(reverse('usercp_credentials'))
+        message = Message(form.non_field_errors()[0], 'error')
+    else:
+        form = CredentialsChangeForm(request=request)
+
+    return request.theme.render_to_response('usercp/credentials.html',
+                                            context_instance=RequestContext(request, {
+                                             'message': message,
+                                             'form': FormLayout(form),
+                                             'tab': 'credentials',
+                                             }));
+
+
+@block_guest
+def activate(request, token):
+    new_credentials = request.session.get('new_credentials')
+    if not new_credentials or new_credentials['token'] != token:
+        return error404(request)
+
+    if new_credentials['new_email']:
+        request.user.set_email(new_credentials['new_email'])
+    if new_credentials['new_password']:
+        request.user.set_password(new_credentials['new_password'])
+
+    try:
+        request.user.full_clean()
+        request.user.save(force_update=True)
+        request.user.sessions.exclude(id=request.session.id).delete()
+        request.user.signin_tokens.all().delete()
+        request.messages.set_flash(Message(_("%(username)s, your Sign-In credentials have been changed.") % {'username': request.user.username}), 'success', 'security')
+        request.session.sign_out(request)
+        del request.session['new_credentials']
+        return redirect(reverse('sign_in'))
+    except ValidationError:
+        request.messages.set_flash(Message(_("Your new credentials have been invalidated. Please try again.")), 'error', 'usercp_credentials')
+        return redirect(reverse('usercp_credentials'))

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


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

@@ -0,0 +1,41 @@
+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.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")}),
+                )
+               ),
+              )

+ 11 - 0
misago/apps/usercp/options/urls.py

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

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

@@ -0,0 +1,4 @@
+from django.utils.translation import ugettext_lazy as _
+
+def register_usercp_extension(request):
+    return (('usercp_options', _('Forum Options')),)

+ 39 - 0
misago/apps/usercp/options/views.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.decorators import block_guest
+from misago.forms import FormLayout
+from misago.messages import Message
+from misago.apps.usercp.options.forms import UserForumOptionsForm
+from misago.apps.usercp.template import RequestContext
+
+@block_guest
+def options(request):
+    message = request.messages.get_message('usercp_options')
+    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.timezone = form.cleaned_data['timezone']
+            request.user.subscribe_start = form.cleaned_data['subscribe_start']
+            request.user.subscribe_reply = form.cleaned_data['subscribe_reply']
+            request.user.save(force_update=True)
+            request.messages.set_flash(Message(_("Forum options have been changed.")), 'success', 'usercp_options')
+            return redirect(reverse('usercp'))
+        message = Message(form.non_field_errors()[0], 'error')
+    else:
+        form = UserForumOptionsForm(request=request, initial={
+                                                             'newsletters': request.user.receive_newsletters,
+                                                             'hide_activity': request.user.hide_activity,
+                                                             'timezone': request.user.timezone,
+                                                             'subscribe_start': request.user.subscribe_start,
+                                                             'subscribe_reply': request.user.subscribe_reply,
+                                                             })
+
+    return request.theme.render_to_response('usercp/options.html',
+                                            context_instance=RequestContext(request, {
+                                             'message': message,
+                                             'tab': 'options',
+                                             'form': FormLayout(form)
+                                             }));

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


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

@@ -0,0 +1,16 @@
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+from misago.forms import Form
+
+
+class SignatureForm(Form):
+    signature = forms.CharField(widget=forms.Textarea, required=False)
+
+    layout = (
+              (
+               None,
+               (
+                ('signature', {'label': _("Your Signature"), 'attrs': {'rows': 10}}),
+                )
+               ),
+              )

+ 12 - 0
misago/apps/usercp/signature/urls.py

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

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

@@ -0,0 +1,5 @@
+from django.utils.translation import ugettext_lazy as _
+
+def register_usercp_extension(request):
+    if request.acl.usercp.can_use_signature():
+        return (('usercp_signature', _('Edit Signature')),)

+ 47 - 0
misago/apps/usercp/signature/views.py

@@ -0,0 +1,47 @@
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from django.utils.translation import ugettext as _
+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.apps.usercp.template import RequestContext
+from misago.apps.usercp.signature.forms import SignatureForm
+
+@block_guest
+def signature(request):
+    # Intercept all requests if we can't use signature
+    if not request.acl.usercp.can_use_signature():
+        return error403(request)
+    if request.user.signature_ban:
+        return request.theme.render_to_response('usercp/signature_banned.html',
+                                                context_instance=RequestContext(request, {
+                                                 'tab': 'signature',
+                                                 }));
+
+    siggy_text = ''
+    message = request.messages.get_message('usercp_signature')
+    if request.method == 'POST':
+        form = SignatureForm(request.POST, request=request, initial={'signature': request.user.signature})
+        if form.is_valid():
+            request.user.signature = form.cleaned_data['signature']
+            if request.user.signature:
+                request.user.signature_preparsed = signature_markdown(request.acl,
+                                                                      request.user.signature)
+            else:
+                request.user.signature_preparsed = None
+            request.user.save(force_update=True)
+            request.messages.set_flash(Message(_("Your signature has been changed.")), 'success', 'usercp_signature')
+            return redirect(reverse('usercp_signature'))
+        else:
+            message = Message(form.non_field_errors()[0], 'error')
+    else:
+        form = SignatureForm(request=request, initial={'signature': request.user.signature})
+
+    return request.theme.render_to_response('usercp/signature.html',
+                                            context_instance=RequestContext(request, {
+                                             'message': message,
+                                             'tab': 'signature',
+                                             'form': FormLayout(form),
+                                             }));

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

@@ -0,0 +1,25 @@
+from django.conf import settings
+from django.template import RequestContext as DjangoRequestContext
+from django.utils.importlib import import_module
+
+def RequestContext(request, context=None):
+    if not context:
+        context = {}
+    context['tabs'] = []
+    for extension in settings.USERCP_EXTENSIONS:
+        usercp_module = import_module(extension + '.usercp')
+        try:
+            append_links = usercp_module.register_usercp_extension(request)
+            if append_links:
+                for link in append_links:
+                    link = list(link)
+                    token = link[0][link[0].find('_') + 1:]
+                    context['tabs'].append({
+                                            'route': link[0],
+                                            'active': context['tab'] == token,
+                                            'name': link[1],
+                                            })
+        except AttributeError:
+            pass
+
+    return DjangoRequestContext(request, context)

+ 22 - 0
misago/apps/usercp/urls.py

@@ -0,0 +1,22 @@
+from django.conf import settings
+from django.conf.urls import include, patterns, url
+from django.utils.importlib import import_module
+
+urlpatterns = []
+iteration = 0
+for extension in settings.USERCP_EXTENSIONS:
+    iteration += 1
+    usercp_module = import_module(extension + '.urls')
+    try:
+        urlpatterns += patterns('',
+            (r'^', include(usercp_module.register_usercp_urls(iteration == 1))),
+        )
+    except AttributeError:
+        pass
+
+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"),
+    url(r'^unignore/(?P<user>\d+)/$', 'unignore', name="unignore_user"),
+)

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


+ 31 - 0
misago/apps/usercp/username/forms.py

@@ -0,0 +1,31 @@
+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.validators import validate_username
+
+class UsernameChangeForm(Form):
+    username = forms.CharField(max_length=255)
+    error_source = 'username'
+
+    layout = [
+              [
+               None,
+               [
+                ('username', {'label': _("Change Username to"), 'help_text': _("Enter new desired username.")}),
+                ],
+               ],
+              ]
+
+    def clean_username(self):
+        org_username = self.request.user.username
+        if org_username == self.cleaned_data['username']:
+            raise ValidationError(_("Your new username is same as current one."))
+        validate_username(self.cleaned_data['username'], self.request.settings)
+        self.request.user.set_username(self.cleaned_data['username'])
+        try:
+            self.request.user.full_clean()
+        except ValidationError as e:
+            self.request.user.is_username_valid(e)
+            self.request.user.set_username(org_username)
+        return self.cleaned_data['username']

+ 11 - 0
misago/apps/usercp/username/urls.py

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

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

@@ -0,0 +1,5 @@
+from django.utils.translation import ugettext_lazy as _
+
+def register_usercp_extension(request):
+    if request.acl.usercp.show_username_change():
+        return (('usercp_username', _('Change Username')),)

+ 71 - 0
misago/apps/usercp/username/views.py

@@ -0,0 +1,71 @@
+from datetime import timedelta
+from django.core.urlresolvers import reverse
+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.apps.errors import error404
+from misago.decorators import block_guest
+from misago.forms import FormLayout
+from misago.messages import Message
+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):
+    if not request.acl.usercp.show_username_change():
+        return error404(request)
+
+    changes_left = request.acl.usercp.changes_left(request.user)
+    next_change = None
+    if request.acl.usercp.changes_expire() and not changes_left:
+        next_change = request.user.namechanges.filter(
+                                                      date__gte=timezone.now() - timedelta(days=request.acl.usercp.acl['changes_expire']),
+                                                      ).order_by('-date')[0]
+        next_change = next_change.date + timedelta(days=request.acl.usercp.acl['changes_expire'])
+
+    message = request.messages.get_message('usercp_username')
+    if request.method == 'POST':
+        if not changes_left:
+            message = Message(_("You have exceeded the maximum number of name changes."), 'error')
+            form = UsernameChangeForm(request=request)
+        else:
+            org_username = request.user.username
+            form = UsernameChangeForm(request.POST, request=request)
+            if form.is_valid():
+                request.user.set_username(form.cleaned_data['username'])
+                request.user.save(force_update=True)
+                request.user.sync_username()
+                request.user.namechanges.create(date=timezone.now(), old_username=org_username)
+                request.messages.set_flash(Message(_("Your username has been changed.")), 'success', 'usercp_username')
+                # Alert followers of namechange
+                alert_time = timezone.now()
+                bulk_alerts = []
+                alerted_users = []
+                for follower in request.user.follows_set.iterator():
+                    alerted_users.append(follower.pk)
+                    alert = Alert(user=follower, message=ugettext_lazy("User that you are following, %(username)s, has changed his name to %(newname)s").message, date=alert_time)
+                    alert.strong('username', org_username)
+                    alert.profile('newname', request.user)
+                    alert.hydrate()
+                    bulk_alerts.append(alert)
+                if bulk_alerts:
+                    Alert.objects.bulk_create(bulk_alerts)
+                    User.objects.filter(id__in=alerted_users).update(alerts=F('alerts') + 1)
+                # Hop back
+                return redirect(reverse('usercp_username'))
+            message = Message(form.non_field_errors()[0], 'error')
+    else:
+        form = UsernameChangeForm(request=request)
+
+    return request.theme.render_to_response('usercp/username.html',
+                                            context_instance=RequestContext(request, {
+                                             'message': message,
+                                             'changes_left': changes_left,
+                                             'form': FormLayout(form),
+                                             'next_change': next_change,
+                                             'changes_history': request.user.namechanges.order_by('-date')[:10],
+                                             'tab': 'username',
+                                             }));

+ 76 - 0
misago/apps/usercp/views.py

@@ -0,0 +1,76 @@
+from django.core.urlresolvers import NoReverseMatch
+from django.shortcuts import redirect
+from django.utils.translation import ugettext as _
+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.models import User
+from misago.utils.translation import ugettext_lazy
+
+def fallback(request):
+    try:
+        return redirect(request.POST.get('fallback', '/'))
+    except NoReverseMatch:
+        return redirect('index')
+
+
+@block_guest
+@check_csrf
+@user_view
+def follow(request, user):
+    if request.user.pk == user.pk:
+        return error404(request)
+    if not request.user.is_following(user):
+        request.messages.set_flash(Message(_("You are now following %(username)s") % {'username': user.username}), 'success')
+        request.user.follows.add(user)
+        request.user.following += 1
+        request.user.save(force_update=True)
+        user.followers += 1
+        if not user.is_ignoring(request.user):
+            alert = user.alert(ugettext_lazy("%(username)s is now following you").message)
+            alert.profile('username', request.user)
+            alert.save_all()
+        else:
+            user.save(force_update=True)
+    return fallback(request)
+
+
+@block_guest
+@check_csrf
+@user_view
+def unfollow(request, user):
+    if request.user.pk == user.pk:
+        return error404(request)
+    if request.user.is_following(user):
+        request.messages.set_flash(Message(_("You have stopped following %(username)s") % {'username': user.username}))
+        request.user.follows.remove(user)
+        request.user.following -= 1
+        request.user.save(force_update=True)
+        user.followers -= 1
+        user.save(force_update=True)
+    return fallback(request)
+
+
+@block_guest
+@check_csrf
+@user_view
+def ignore(request, user):
+    if request.user.pk == user.pk:
+        return error404(request)
+    if not request.user.is_ignoring(user):
+        request.messages.set_flash(Message(_("You are now ignoring %(username)s") % {'username': user.username}), 'success')
+        request.user.ignores.add(user)
+    return fallback(request)
+
+
+@block_guest
+@check_csrf
+@user_view
+def unignore(request, user):
+    if request.user.pk == user.pk:
+        return error404(request)
+    if request.user.is_ignoring(user):
+        request.messages.set_flash(Message(_("You have stopped ignoring %(username)s") % {'username': user.username}))
+        request.user.ignores.remove(user)
+    return fallback(request)

+ 0 - 0
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}),
+)

+ 40 - 0
misago/apps/watchedthreads/views.py

@@ -0,0 +1,40 @@
+from django import forms
+from django.core.urlresolvers import reverse
+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.decorators import block_guest
+from misago.forms import Form, FormLayout, FormFields
+from misago.messages import Message
+from misago.models import WatchedThread
+from misago.utils.pagination import make_pagination
+
+@block_guest
+def watched_threads(request, page=0, new=False):
+    # Find mode and fetch threads
+    queryset = WatchedThread.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)
+    if new:
+        queryset = queryset.filter(last_read__lt=F('thread__last'))
+    count = queryset.count()
+    pagination = make_pagination(page, count, request.settings.threads_per_page)
+    queryset = queryset.order_by('-thread__last')
+    if request.settings.threads_per_page < count:
+        queryset = queryset[pagination['start']:pagination['stop']]
+    queryset.prefetch_related('thread__forum', 'thread__start_poster', 'thread__last_poster')
+    threads = []
+    for thread in queryset:
+        thread.thread.send_email = thread.email
+        thread.thread.is_read = thread.thread.last <= thread.last_read             
+        threads.append(thread.thread)
+            
+    # Display page
+    return request.theme.render_to_response('watched.html',
+                                            {
+                                             'items_total': count,
+                                             'pagination': pagination,
+                                             'new': new,
+                                             'threads': threads,
+                                             'message': request.messages.get_message('threads'),
+                                             },
+                                            context_instance=RequestContext(request))

+ 2 - 2
misago/auth.py

@@ -81,7 +81,7 @@ def auth_remember(request, ip):
         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
@@ -97,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

+ 0 - 4
misago/decorators.py

@@ -17,7 +17,6 @@ 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
@@ -28,7 +27,6 @@ def block_banned(f):
         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:
@@ -50,7 +48,6 @@ 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
@@ -72,7 +69,6 @@ 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 - 18
misago/forms/captcha.py

@@ -1,18 +0,0 @@
-from recaptcha.client.captcha import API_SSL_SERVER, API_SERVER, VERIFY_SERVER
-from django.forms.fields import CharField
-from django.forms.widgets import TextInput
-
-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

+ 1 - 0
misago/forms/fields.py

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

+ 2 - 1
misago/models/banmodel.py

@@ -8,8 +8,9 @@ BAN_NAME = 1
 BAN_EMAIL = 2
 BAN_IP = 3
 
+
 class BansManager(models.Manager):
-    def check_ban(ip=False, username=False, email=False):
+    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:

+ 3 - 1
misago/models/forumreadmodel.py

@@ -14,8 +14,10 @@ class ForumRead(models.Model):
         app_label = 'misago'
 
     def get_threads(self):
+        from misago.models import ThreadRead
+        
         threads = {}
-        for thread in ThreadRecord.objects.filter(user=self.user, forum=self.forum, updated__gte=(timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH))):
+        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
 

+ 1 - 1
misago/models/postmodel.py

@@ -73,7 +73,7 @@ class Post(models.Model):
             
     def notify_mentioned(self, request, users):
         from misago.acl.builder import acl
-        from misago.acl.utils import ACLError403, ACLError404
+        from misago.acl.exceptions import ACLError403, ACLError404
         
         mentioned = self.mentions.all()
         for slug, user in users.items():

+ 2 - 2
misago/models/threadmodel.py

@@ -129,10 +129,10 @@ class Thread(models.Model):
         
     def email_watchers(self, request, post):
         from misago.acl.builder import acl
-        from misago.acl.utils import ACLError403, ACLError404
+        from misago.acl.exceptions import ACLError403, ACLError404
         from misago.models import ThreadRead
 
-        for watch in ThreadWatch.objects.filter(thread=self).filter(email=True).filter(last_read__gte=self.previous_last):
+        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:

+ 10 - 10
misago/settings_base.py

@@ -116,20 +116,20 @@ PERMISSION_PROVIDERS = (
 
 # List of UserCP extensions
 USERCP_EXTENSIONS = (
-    'misago.apps.front.usercp.options',
-    'misago.apps.front.usercp.avatar',
-    'misago.apps.front.usercp.signature',
-    'misago.apps.front.usercp.credentials',
-    'misago.apps.front.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.apps.front.profiles.posts',
-    'misago.apps.front.profiles.threads',
-    'misago.apps.front.profiles.follows',
-    'misago.apps.front.profiles.followers',
-    'misago.apps.front.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

+ 18 - 22
misago/urls.py

@@ -7,33 +7,29 @@ from misago.admin import ADMIN_PATH, site
 urlpatterns = patterns('misago.apps',
     url(r'^$', 'index.index', name="index"),
     url(r'^read-all/$', 'readall.read_all', name="read_all"),
-    (r'^', include('misago.apps.signin.urls')),
+    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"),
 )
 
-# Remove after ACP was refactored
 urlpatterns += patterns('',
-    url(r'^users/(?P<username>\w+)-(?P<user>\d+)/$', 'misago.apps.admin.index.todo', name="user"),
+    (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'^reset-password/', include('misago.apps.resetpswd.urls')),
+    (r'^', include('misago.apps.threads.urls')),
+    (r'^watched-threads/', include('misago.apps.watchedthreads.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'^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'^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"),
-"""
 
 # Include admin patterns
 if ADMIN_PATH:

+ 1 - 0
misago/utils/datesformats.py

@@ -1,3 +1,4 @@
+import math
 from datetime import datetime, timedelta
 from django.utils.dateformat import format, time_format
 from django.utils.formats import get_format