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 django.utils.translation import ugettext as _
 from misago.apps.threadtype.list import ThreadsListBaseView, ThreadsListModeration
 from misago.models import Forum, Thread
 from misago.readstrackers import ThreadsTracker

+ 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):
-    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.utils.translation import ugettext as _
 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.models import Forum, Thread, Post
 from misago.apps.threads.mixins import TypeMixin
@@ -17,7 +17,6 @@ class NewThreadView(NewThreadBaseView, TypeMixin):
         self.request.acl.threads.allow_new_threads(self.proxy)
 
     def response(self):
-        # Set flash and redirect user to his post
         if self.post.moderated:
             self.request.messages.set_flash(Message(_("New thread has been posted. It will be hidden from other members until moderator reviews it.")), 'success', 'threads')
         else:
@@ -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))
 
 
-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
 
-class EditReplyView(NewThreadBaseView, TypeMixin):
+class EditReplyView(EditReplyBaseView, TypeMixin):
     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+)/(?P<quote>\d+)/reply/$', 'posting.NewReplyView', name="thread_reply"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/edit/$', 'posting.EditReplyView', name="post_edit"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/$', 'thread.ThreadView', name="thread"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<page>\d+)/$', 'thread.ThreadView', name="thread"),
 )
 
 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+)/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"),
@@ -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+)/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+)/(?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+)/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'}),

+ 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.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)
         return obj(request, **kwargs)
 
+    def form_initial_data(self):
+        return {}
+
     def _set_context(self):
         self.set_context()
         if self.forum.level:
             self.parents = Forum.objects.forum_parents(self.forum.pk)
 
+    def record_edit(self, form, old_name, old_post):
+        self.post.change_set.create(
+                                    forum=self.forum,
+                                    thread=self.thread,
+                                    post=self.post,
+                                    user=self.request.user,
+                                    user_name=self.request.user.username,
+                                    user_slug=self.request.user.username_slug,
+                                    date=self.post.edit_date,
+                                    ip=self.request.session.get_ip(self.request),
+                                    agent=self.request.META.get('HTTP_USER_AGENT'),
+                                    reason=form.cleaned_data['edit_reason'],
+                                    size=len(self.post.post),
+                                    change=len(self.post.post) - len(old_post),
+                                    thread_name_old=old_name if 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):
         if self.request.user.subscribe_start:
             try:
@@ -64,7 +86,7 @@ class PostingBaseView(object):
                     else:
                         message = Message(form.non_field_errors()[0], 'error')
             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:
             return error404(request)
         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.conf import settings
 from django.utils.translation import ugettext_lazy as _
 from misago.apps.threadtype.mixins import ValidateThreadNameMixin
 from misago.forms import Form
@@ -14,14 +15,13 @@ class PostingForm(Form):
 
     def set_extra_fields(self):
         # 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 = []
-            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")))
-            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:
                 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)
@@ -52,3 +52,11 @@ class NewThreadForm(PostingForm, ValidateThreadNameMixin):
                                                                                     _("Thread name is too long. Try shorter name."))])
 
         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));