Ralfp 12 лет назад
Родитель
Сommit
cf3c77f641

+ 1 - 1
misago/apps/threads/list.py

@@ -1,5 +1,5 @@
-from django.utils.translation import ugettext as _
 from itertools import chain
 from itertools import chain
+from django.utils.translation import ugettext as _
 from misago.apps.threadtype.list import ThreadsListBaseView, ThreadsListModeration
 from misago.apps.threadtype.list import ThreadsListBaseView, ThreadsListModeration
 from misago.models import Forum, Thread
 from misago.models import Forum, Thread
 from misago.readstrackers import ThreadsTracker
 from misago.readstrackers import ThreadsTracker

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

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

+ 19 - 6
misago/apps/threads/posting.py

@@ -2,7 +2,7 @@ from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
 from django.shortcuts import redirect
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
 from misago.apps.threadtype.mixins import RedirectToPostMixin
 from misago.apps.threadtype.mixins import RedirectToPostMixin
-from misago.apps.threadtype.posting import NewThreadBaseView
+from misago.apps.threadtype.posting import NewThreadBaseView, EditThreadBaseView, NewReplyBaseView, EditReplyBaseView
 from misago.messages import Message
 from misago.messages import Message
 from misago.models import Forum, Thread, Post
 from misago.models import Forum, Thread, Post
 from misago.apps.threads.mixins import TypeMixin
 from misago.apps.threads.mixins import TypeMixin
@@ -17,7 +17,6 @@ class NewThreadView(NewThreadBaseView, TypeMixin):
         self.request.acl.threads.allow_new_threads(self.proxy)
         self.request.acl.threads.allow_new_threads(self.proxy)
 
 
     def response(self):
     def response(self):
-        # Set flash and redirect user to his post
         if self.post.moderated:
         if self.post.moderated:
             self.request.messages.set_flash(Message(_("New thread has been posted. It will be hidden from other members until moderator reviews it.")), 'success', 'threads')
             self.request.messages.set_flash(Message(_("New thread has been posted. It will be hidden from other members until moderator reviews it.")), 'success', 'threads')
         else:
         else:
@@ -25,12 +24,26 @@ class NewThreadView(NewThreadBaseView, TypeMixin):
         return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % self.post.pk))
         return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % self.post.pk))
 
 
 
 
-class EditThreadView(NewThreadBaseView, TypeMixin):
-    pass
+class EditThreadView(EditThreadBaseView, TypeMixin):
+    action = 'edit_thread'
+
+    def set_context(self):
+        self.thread = Thread.objects.get(pk=self.kwargs.get('thread'))
+        self.forum = self.thread.forum
+        self.proxy = Forum.objects.parents_aware_forum(self.forum)
+        self.request.acl.forums.allow_forum_view(self.forum)
+        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
+        self.post = self.thread.start_post
+        self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
+        self.request.acl.threads.allow_thread_edit(self.request.user, self.proxy, self.thread, self.post)
+    
+    def response(self):
+        self.request.messages.set_flash(Message(_("Your thread has been edited.")), 'success', 'threads_%s' % self.post.pk)
+        return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % self.post.pk))
 
 
 
 
-class NewReplyView(NewThreadBaseView, TypeMixin):
+class NewReplyView(NewReplyBaseView, TypeMixin):
     pass
     pass
 
 
-class EditReplyView(NewThreadBaseView, TypeMixin):
+class EditReplyView(EditReplyBaseView, TypeMixin):
     pass
     pass

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

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

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

@@ -8,10 +8,11 @@ urlpatterns = patterns('misago.apps.threads',
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/reply/$', 'posting.NewReplyView', name="thread_reply"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/reply/$', 'posting.NewReplyView', name="thread_reply"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<quote>\d+)/reply/$', 'posting.NewReplyView', name="thread_reply"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<quote>\d+)/reply/$', 'posting.NewReplyView', name="thread_reply"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/edit/$', 'posting.EditReplyView', name="post_edit"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/edit/$', 'posting.EditReplyView', name="post_edit"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/$', 'thread.ThreadView', name="thread"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<page>\d+)/$', 'thread.ThreadView', name="thread"),
 )
 )
 
 
 urlpatterns += patterns('misago.apps.errors',
 urlpatterns += patterns('misago.apps.errors',
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/$', 'error_not_implemented', name="thread"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/last/$', 'error_not_implemented', name="thread_last"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/last/$', 'error_not_implemented', name="thread_last"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/find-(?P<post>\d+)/$', 'error_not_implemented', name="thread_find"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/find-(?P<post>\d+)/$', 'error_not_implemented', name="thread_find"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/new/$', 'error_not_implemented', name="thread_new"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/new/$', 'error_not_implemented', name="thread_new"),
@@ -22,7 +23,6 @@ urlpatterns += patterns('misago.apps.errors',
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/email/$', 'error_not_implemented', name="thread_watch_email"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/email/$', 'error_not_implemented', name="thread_watch_email"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/$', 'error_not_implemented', name="thread_unwatch"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/$', 'error_not_implemented', name="thread_unwatch"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/email/$', 'error_not_implemented', name="thread_unwatch_email"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/email/$', 'error_not_implemented', name="thread_unwatch_email"),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<page>\d+)/$', 'error_not_implemented', name="thread"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<quote>\d+)/reply/$', 'error_not_implemented', name="thread_reply"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<quote>\d+)/reply/$', 'error_not_implemented', name="thread_reply"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/delete/$', 'error_not_implemented', name="thread_delete", kwargs={'mode': 'delete_thread'}),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/delete/$', 'error_not_implemented', name="thread_delete", kwargs={'mode': 'delete_thread'}),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/hide/$', 'error_not_implemented', name="thread_hide", kwargs={'mode': 'hide_thread'}),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/hide/$', 'error_not_implemented', name="thread_hide", kwargs={'mode': 'hide_thread'}),

+ 3 - 3
misago/apps/threadtype/posting/__init__.py

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

+ 23 - 1
misago/apps/threadtype/posting/base.py

@@ -12,11 +12,33 @@ class PostingBaseView(object):
         obj = super(PostingBaseView, cls).__new__(cls)
         obj = super(PostingBaseView, cls).__new__(cls)
         return obj(request, **kwargs)
         return obj(request, **kwargs)
 
 
+    def form_initial_data(self):
+        return {}
+
     def _set_context(self):
     def _set_context(self):
         self.set_context()
         self.set_context()
         if self.forum.level:
         if self.forum.level:
             self.parents = Forum.objects.forum_parents(self.forum.pk)
             self.parents = Forum.objects.forum_parents(self.forum.pk)
 
 
+    def record_edit(self, form, old_name, old_post):
+        self.post.change_set.create(
+                                    forum=self.forum,
+                                    thread=self.thread,
+                                    post=self.post,
+                                    user=self.request.user,
+                                    user_name=self.request.user.username,
+                                    user_slug=self.request.user.username_slug,
+                                    date=self.post.edit_date,
+                                    ip=self.request.session.get_ip(self.request),
+                                    agent=self.request.META.get('HTTP_USER_AGENT'),
+                                    reason=form.cleaned_data['edit_reason'],
+                                    size=len(self.post.post),
+                                    change=len(self.post.post) - len(old_post),
+                                    thread_name_old=old_name if form.cleaned_data['thread_name'] != old_name else None,
+                                    thread_name_new=self.thread.name if form.cleaned_data['thread_name'] != old_name else None,
+                                    post_content=old_post,
+                                    )
+
     def watch_thread(self):
     def watch_thread(self):
         if self.request.user.subscribe_start:
         if self.request.user.subscribe_start:
             try:
             try:
@@ -64,7 +86,7 @@ class PostingBaseView(object):
                     else:
                     else:
                         message = Message(form.non_field_errors()[0], 'error')
                         message = Message(form.non_field_errors()[0], 'error')
             else:
             else:
-                form = self.form_type(request=request, forum=self.forum, thread=self.thread)
+                form = self.form_type(request=request, forum=self.forum, thread=self.thread, initial=self.form_initial_data())
         except Forum.DoesNotExist:
         except Forum.DoesNotExist:
             return error404(request)
             return error404(request)
         except ACLError403 as e:
         except ACLError403 as e:

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

@@ -0,0 +1,2 @@
+class EditReplyBaseView(object):
+    pass

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

@@ -0,0 +1,62 @@
+from datetime import timedelta
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from misago.apps.threadtype.posting.base import PostingBaseView
+from misago.apps.threadtype.posting.forms import EditThreadForm
+from misago.markdown import post_markdown
+from misago.messages import Message
+from misago.models import Forum, Thread, Post
+from misago.utils.strings import slugify
+
+class EditThreadBaseView(PostingBaseView):
+    form_type = EditThreadForm
+
+    def form_initial_data(self):
+        return {
+                'thread_name': self.thread.name,
+                'weight': self.thread.weight,
+                'post': self.post.post,
+                }
+
+    def post_form(self, form):
+        now = timezone.now()
+        old_name = self.thread.name
+        old_post = self.post.post
+
+        changed_thread = old_name != form.cleaned_data['thread_name']
+        changed_post = old_post != form.cleaned_data['post']
+        changed_anything = changed_thread or changed_post
+
+        if 'close_thread' in form.cleaned_data and form.cleaned_data['close_thread']:
+            self.thread.closed = not self.thread.closed
+            changed_thread = True
+            if self.thread.closed:
+                self.thread.last_post.set_checkpoint(self.request, 'closed')
+            else:
+                self.thread.last_post.set_checkpoint(self.request, 'opened')
+
+        if ('thread_weight' in form.cleaned_data and
+                form.cleaned_data['thread_weight'] != self.thread.weight):
+            self.thread.weight = form.cleaned_data['thread_weight']
+            changed_thread = True
+
+        if changed_thread:
+            self.thread.name = form.cleaned_data['thread_name']
+            self.thread.slug = slugify(form.cleaned_data['thread_name'])
+            self.thread.save(force_update=True)
+
+        if changed_post:
+            md, self.post.post_preparsed = post_markdown(self.request, form.cleaned_data['post'])
+            self.post.edits += 1
+            self.post.edit_date = now
+            self.post.edit_user = self.request.user
+            self.post.edit_user_name = self.request.user.username
+            self.post.edit_user_slug = self.request.user.username_slug
+            if md.mentions:
+                post.notify_mentioned(self.request, md.mentions)
+            self.post.save(force_update=True)
+
+        if changed_anything:
+            self.record_edit(form, old_name, old_post)

+ 14 - 6
misago/apps/threadtype/posting/forms.py

@@ -1,4 +1,5 @@
 from django import forms
 from django import forms
+from django.conf import settings
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ugettext_lazy as _
 from misago.apps.threadtype.mixins import ValidateThreadNameMixin
 from misago.apps.threadtype.mixins import ValidateThreadNameMixin
 from misago.forms import Form
 from misago.forms import Form
@@ -14,14 +15,13 @@ class PostingForm(Form):
 
 
     def set_extra_fields(self):
     def set_extra_fields(self):
         # Can we change threads states?
         # Can we change threads states?
-        if self.request.acl.threads.can_pin_threads(self.forum):
+        if (self.request.acl.threads.can_pin_threads(self.forum) >= self.thread.weight and
+                self.request.acl.threads.can_pin_threads(self.forum)):
             thread_weight = []
             thread_weight = []
-            if (not self.thread or self.thread.weight < 2) and self.request.acl.threads.can_pin_threads(self.forum) == 2:
+            if self.request.acl.threads.can_pin_threads(self.forum) == 2:
                 thread_weight.append((2, _("Announcement")))
                 thread_weight.append((2, _("Announcement")))
-            if (not self.thread or self.thread.weight == 0) and self.request.acl.threads.can_pin_threads(self.forum):
-                thread_weight.append((1, _("Sticky")))
-            if (not self.thread or self.thread.weight != 0):
-                thread_weight.append((0, _("Standard")))
+            thread_weight.append((1, _("Sticky")))
+            thread_weight.append((0, _("Standard")))
             if thread_weight:
             if thread_weight:
                 self.layout[0][1].append(('thread_weight', {'label': _("Thread Importance")}))
                 self.layout[0][1].append(('thread_weight', {'label': _("Thread Importance")}))
                 self.fields['thread_weight'] = forms.TypedChoiceField(widget=forms.RadioSelect, choices=thread_weight, coerce=int, initial=0)
                 self.fields['thread_weight'] = forms.TypedChoiceField(widget=forms.RadioSelect, choices=thread_weight, coerce=int, initial=0)
@@ -52,3 +52,11 @@ class NewThreadForm(PostingForm, ValidateThreadNameMixin):
                                                                                     _("Thread name is too long. Try shorter name."))])
                                                                                     _("Thread name is too long. Try shorter name."))])
 
 
         self.set_extra_fields()
         self.set_extra_fields()
+
+
+class EditThreadForm(NewThreadForm, ValidateThreadNameMixin):
+    def finalize_form(self):
+        super(EditThreadForm, self).finalize_form()
+        self.fields['edit_reason'] = forms.CharField(max_length=255, required=False, help_text=_("Optional reason for editing this thread."))
+        self.layout[0][1].append(('edit_reason', {'label': _("Edit Reason")}))
+

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

@@ -0,0 +1,2 @@
+class NewReplyBaseView(object):
+    pass

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

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

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

@@ -0,0 +1,5 @@
+from django import forms
+from misago.forms import Form
+
+class QuickReplyForm(Form):
+    post = forms.CharField(widget=forms.Textarea)

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


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

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

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

@@ -0,0 +1,211 @@
+from django.core.urlresolvers import reverse
+from django import forms
+from django.shortcuts import redirect
+from django.template import RequestContext
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from misago.forms import FormLayout
+from misago.markdown import post_markdown
+from misago.messages import Message
+from misago.utils.strings import slugify
+from misago.apps.threadtype.thread.moderation.forms import SplitThreadForm, MovePostsForm
+
+class PostsModeration(object):
+    def post_action_accept(self, ids):
+        accepted = 0
+        for post in self.posts:
+            if post.pk in ids and post.moderated:
+                accepted += 1
+        if accepted:
+            self.thread.post_set.filter(id__in=ids).update(moderated=False)
+            self.thread.sync()
+            self.thread.save(force_update=True)
+            self.request.messages.set_flash(Message(_('Selected posts have been accepted and made visible to other members.')), 'success', 'threads')
+
+    def post_action_merge(self, ids):
+        users = []
+        posts = []
+        for post in self.posts:
+            if post.pk in ids:
+                posts.append(post)
+                if not post.user_id in users:
+                    users.append(post.user_id)
+                if len(users) > 1:
+                    raise forms.ValidationError(_("You cannot merge replies made by different members!"))
+        if len(posts) < 2:
+            raise forms.ValidationError(_("You have to select two or more posts you want to merge."))
+        new_post = posts[0]
+        for post in posts[1:]:
+            post.merge_with(new_post)
+            post.delete()
+        md, new_post.post_preparsed = post_markdown(self.request, new_post.post)
+        new_post.save(force_update=True)
+        self.thread.sync()
+        self.thread.save(force_update=True)
+        self.forum.sync()
+        self.forum.save(force_update=True)
+        self.request.messages.set_flash(Message(_('Selected posts have been merged into one message.')), 'success', 'threads')
+
+    def post_action_split(self, ids):
+        for id in ids:
+            if id == self.thread.start_post_id:
+                raise forms.ValidationError(_("You cannot split first post from thread."))
+        message = None
+        if self.request.POST.get('do') == 'split':
+            form = SplitThreadForm(self.request.POST, request=self.request)
+            if form.is_valid():
+                new_thread = Thread()
+                new_thread.forum = form.cleaned_data['thread_forum']
+                new_thread.name = form.cleaned_data['thread_name']
+                new_thread.slug = slugify(form.cleaned_data['thread_name'])
+                new_thread.start = timezone.now()
+                new_thread.last = timezone.now()
+                new_thread.start_poster_name = 'n'
+                new_thread.start_poster_slug = 'n'
+                new_thread.last_poster_name = 'n'
+                new_thread.last_poster_slug = 'n'
+                new_thread.save(force_insert=True)
+                prev_merge = -1
+                merge = -1
+                for post in self.posts:
+                    if post.pk in ids:
+                        if prev_merge != post.merge:
+                            prev_merge = post.merge
+                            merge += 1
+                        post.merge = merge
+                        post.move_to(new_thread)
+                        post.save(force_update=True)
+                new_thread.sync()
+                new_thread.save(force_update=True)
+                self.thread.sync()
+                self.thread.save(force_update=True)
+                self.forum.sync()
+                self.forum.save(force_update=True)
+                if new_thread.forum != self.forum:
+                    new_thread.forum.sync()
+                    new_thread.forum.save(force_update=True)
+                self.request.messages.set_flash(Message(_("Selected posts have been split to new thread.")), 'success', 'threads')
+                return redirect(reverse(self.thread_url, 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(self.thread_url, 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')

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

@@ -0,0 +1,129 @@
+from django.template import RequestContext
+from django.utils.translation import ugettext as _
+from misago.forms import Form, FormLayout
+from misago.messages import Message
+from misago.apps.threadtype.list.forms import MoveThreadsForm
+
+class ThreadModeration(object):
+    def thread_action_accept(self):
+        # Sync thread and post
+        self.thread.moderated = False
+        self.thread.replies_moderated -= 1
+        self.thread.save(force_update=True)
+        self.thread.start_post.moderated = False
+        self.thread.start_post.save(force_update=True)
+        self.thread.last_post.set_checkpoint(self.request, 'accepted')
+        # Sync user
+        if self.thread.last_post.user:
+            self.thread.start_post.user.threads += 1
+            self.thread.start_post.user.posts += 1
+            self.thread.start_post.user.save(force_update=True)
+        # Sync forum
+        self.forum.sync()
+        self.forum.save(force_update=True)
+        # Update monitor
+        self.request.monitor['threads'] = int(self.request.monitor['threads']) + 1
+        self.request.monitor['posts'] = int(self.request.monitor['posts']) + self.thread.replies + 1
+        self.request.messages.set_flash(Message(_('Thread has been marked as reviewed and made visible to other members.')), 'success', 'threads')
+
+    def thread_action_annouce(self):
+        self.thread.weight = 2
+        self.thread.save(force_update=True)
+        self.request.messages.set_flash(Message(_('Thread has been turned into announcement.')), 'success', 'threads')
+
+    def thread_action_sticky(self):
+        self.thread.weight = 1
+        self.thread.save(force_update=True)
+        self.request.messages.set_flash(Message(_('Thread has been turned into sticky.')), 'success', 'threads')
+
+    def thread_action_normal(self):
+        self.thread.weight = 0
+        self.thread.save(force_update=True)
+        self.request.messages.set_flash(Message(_('Thread weight has been changed to normal.')), 'success', 'threads')
+
+    def thread_action_move(self):
+        message = None
+        if self.request.POST.get('do') == 'move':
+            form = MoveThreadsForm(self.request.POST, request=self.request, forum=self.forum)
+            if form.is_valid():
+                new_forum = form.cleaned_data['new_forum']
+                self.thread.move_to(new_forum)
+                self.thread.save(force_update=True)
+                self.forum.sync()
+                self.forum.save(force_update=True)
+                new_forum.sync()
+                new_forum.save(force_update=True)
+                self.request.messages.set_flash(Message(_('Thread has been moved to "%(forum)s".') % {'forum': new_forum.name}), 'success', 'threads')
+                return None
+            message = Message(form.non_field_errors()[0], 'error')
+        else:
+            form = MoveThreadsForm(request=self.request, forum=self.forum)
+        return self.request.theme.render_to_response('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 self.threads_list_redirect()

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

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