Browse Source

isort, moving editor formset to Misago 0.6, threads/editor/ endpoint

Rafał Pitoń 8 years ago
parent
commit
9ebcaf97e3
40 changed files with 926 additions and 49 deletions
  1. 1 2
      misago/admin/views/generic/list.py
  2. 21 11
      misago/categories/migrations/0003_categories_roles.py
  3. 1 0
      misago/conf/hydrators.py
  4. 2 2
      misago/core/shortcuts.py
  5. 2 2
      misago/core/templatetags/misago_forms.py
  6. 1 0
      misago/core/tests/test_apipaginator.py
  7. 1 1
      misago/core/tests/test_serializer.py
  8. 1 1
      misago/core/tests/test_setup.py
  9. 1 2
      misago/core/utils.py
  10. 1 2
      misago/faker/management/commands/createfakebans.py
  11. 2 3
      misago/faker/management/commands/createfakecategories.py
  12. 1 2
      misago/faker/management/commands/createfakethreads.py
  13. 1 2
      misago/faker/management/commands/createfakeusers.py
  14. 2 1
      misago/markup/parser.py
  15. 2 2
      misago/markup/pipeline.py
  16. 40 0
      misago/threads/api/threads.py
  17. 2 1
      misago/threads/checksums.py
  18. 25 6
      misago/threads/permissions/threads.py
  19. 177 0
      misago/threads/posting/__init__.py
  20. 40 0
      misago/threads/posting/floodprotection.py
  21. 20 0
      misago/threads/posting/participants.py
  22. 23 0
      misago/threads/posting/recordedit.py
  23. 81 0
      misago/threads/posting/reply.py
  24. 40 0
      misago/threads/posting/savechanges.py
  25. 33 0
      misago/threads/posting/threadclose.py
  26. 50 0
      misago/threads/posting/threadlabel.py
  27. 33 0
      misago/threads/posting/threadpin.py
  28. 40 0
      misago/threads/posting/updatestats.py
  29. 1 1
      misago/threads/tests/test_gotoviews.py
  30. 1 0
      misago/threads/tests/test_thread_patch_api.py
  31. 269 0
      misago/threads/tests/test_threads_editor_api.py
  32. 2 2
      misago/users/avatars/store.py
  33. 1 0
      misago/users/credentialchange.py
  34. 1 0
      misago/users/migrations/0005_dj_19_update.py
  35. 1 1
      misago/users/tests/test_auth_api.py
  36. 1 1
      misago/users/tests/test_auth_views.py
  37. 1 1
      misago/users/tests/test_captcha_api.py
  38. 1 0
      misago/users/tests/test_testutils.py
  39. 1 1
      misago/users/tests/test_useradmin_views.py
  40. 2 2
      misago/users/views/lists.py

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

@@ -1,5 +1,3 @@
-from six.moves.urllib.parse import urlencode
-
 from django.contrib import messages
 from django.core.paginator import EmptyPage, Paginator
 from django.core.urlresolvers import reverse
@@ -8,6 +6,7 @@ from django.shortcuts import redirect
 from django.utils.translation import ugettext_lazy as _
 
 from misago.core.exceptions import ExplicitFirstPage
+from six.moves.urllib.parse import urlencode
 
 from .base import AdminView
 

+ 21 - 11
misago/categories/migrations/0003_categories_roles.py

@@ -23,7 +23,7 @@ def create_default_categories_roles(apps, schema_editor):
             # categories perms
             'misago.categories.permissions': {
                 'can_see': 1,
-                'can_browse': 0,
+                'can_browse': 0
             },
         })
     see_only.save()
@@ -34,14 +34,15 @@ def create_default_categories_roles(apps, schema_editor):
             # categories perms
             'misago.categories.permissions': {
                 'can_see': 1,
-                'can_browse': 1,
+                'can_browse': 1
             },
 
             # threads perms
             'misago.threads.permissions.threads': {
                 'can_see_all_threads': 1,
                 'can_see_posts_likes': 2,
-                'can_like_posts': 1,
+                'can_download_other_users_attachments': 1,
+                'can_like_posts': 1
             },
         })
     read_only.save()
@@ -52,7 +53,7 @@ def create_default_categories_roles(apps, schema_editor):
             # categories perms
             'misago.categories.permissions': {
                 'can_see': 1,
-                'can_browse': 1,
+                'can_browse': 1
             },
 
             # threads perms
@@ -60,8 +61,10 @@ def create_default_categories_roles(apps, schema_editor):
                 'can_see_all_threads': 1,
                 'can_reply_threads': 1,
                 'can_edit_posts': 1,
+                'can_download_other_users_attachments': 1,
+                'max_attachment_size': 500,
                 'can_see_posts_likes': 2,
-                'can_like_posts': 1,
+                'can_like_posts': 1
             },
         })
     reply_only.save()
@@ -72,7 +75,7 @@ def create_default_categories_roles(apps, schema_editor):
             # categories perms
             'misago.categories.permissions': {
                 'can_see': 1,
-                'can_browse': 1,
+                'can_browse': 1
             },
 
             # threads perms
@@ -82,8 +85,10 @@ def create_default_categories_roles(apps, schema_editor):
                 'can_reply_threads': 1,
                 'can_edit_threads': 1,
                 'can_edit_posts': 1,
+                'can_download_other_users_attachments': 1,
+                'max_attachment_size': 500,
                 'can_see_posts_likes': 2,
-                'can_like_posts': 1,
+                'can_like_posts': 1
             },
         })
     standard.save()
@@ -104,8 +109,10 @@ def create_default_categories_roles(apps, schema_editor):
                 'can_reply_threads': 1,
                 'can_edit_threads': 1,
                 'can_edit_posts': 1,
+                'can_download_other_users_attachments': 1,
+                'max_attachment_size': 500,
                 'can_see_posts_likes': 2,
-                'can_like_posts': 1,
+                'can_like_posts': 1
             },
         })
     standard_with_polls.save()
@@ -116,7 +123,7 @@ def create_default_categories_roles(apps, schema_editor):
             # categories perms
             'misago.categories.permissions': {
                 'can_see': 1,
-                'can_browse': 1,
+                'can_browse': 1
             },
 
             # threads perms
@@ -142,11 +149,14 @@ def create_default_categories_roles(apps, schema_editor):
                 'can_merge_threads': 1,
                 'can_split_threads': 1,
                 'can_approve_content': 1,
-                'can_report_content': 1,
+                'can_download_other_users_attachments': 1,
+                'max_attachment_size': 2500,
+                'can_delete_other_users_attachments': 1,
                 'can_see_posts_likes': 2,
                 'can_like_posts': 1,
+                'can_report_content': 1,
                 'can_see_reports': 1,
-                'can_hide_events': 2,
+                'can_hide_events': 2
             },
         })
     moderator.save()

+ 1 - 0
misago/conf/hydrators.py

@@ -1,5 +1,6 @@
 import six
 
+
 def hydrate_string(dry_value):
     return six.text_type(dry_value) if dry_value else ''
 

+ 2 - 2
misago/core/shortcuts.py

@@ -1,10 +1,10 @@
 from collections import OrderedDict
 
+import six
+
 from django.http import Http404
 from django.shortcuts import *  # noqa
 
-import six
-
 
 def paginate(object_list, page, per_page, orphans=0,
              allow_empty_first_page=True,

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

@@ -1,9 +1,9 @@
-from crispy_forms.templatetags import crispy_forms_field, crispy_forms_filters
-
 from django import template
 from django.template import Context
 from django.template.loader import get_template
 
+from crispy_forms.templatetags import crispy_forms_field, crispy_forms_filters
+
 
 register = template.Library()
 

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

@@ -1,5 +1,6 @@
 from django.test import TestCase
 from django.utils.six.moves import range
+
 from ..apipaginator import ApiPaginator
 
 

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

@@ -1,5 +1,5 @@
-from django.utils.six.moves import range
 from django.test import TestCase
+from django.utils.six.moves import range
 
 from .. import serializer
 

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

@@ -1,7 +1,7 @@
 import os
 
-from django.utils.encoding import smart_str
 from django.test import TestCase
+from django.utils.encoding import smart_str
 
 from .. import setup
 

+ 1 - 2
misago/core/utils.py

@@ -1,5 +1,6 @@
 from datetime import timedelta
 
+import six
 from unidecode import unidecode
 
 from django.core.urlresolvers import resolve, reverse
@@ -9,8 +10,6 @@ from django.utils import html, timezone
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ungettext_lazy
 
-import six
-
 
 def slugify(string):
     string = six.text_type(string)

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

@@ -2,12 +2,11 @@ import random
 import sys
 from datetime import timedelta
 
-from faker import Factory
-
 from django.core.management.base import BaseCommand
 from django.utils import timezone
 from django.utils.six.moves import range
 
+from faker import Factory
 from misago.core.management.progressbar import show_progress
 from misago.users.models import BAN_EMAIL, BAN_IP, BAN_USERNAME, Ban
 

+ 2 - 3
misago/faker/management/commands/createfakecategories.py

@@ -2,11 +2,10 @@ import random
 import sys
 import time
 
-from django.utils.six.moves import range
-from faker import Factory
-
 from django.core.management.base import BaseCommand
+from django.utils.six.moves import range
 
+from faker import Factory
 from misago.acl import version as acl_version
 from misago.categories.models import Category, RoleCategoryACL
 from misago.core.management.progressbar import show_progress

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

@@ -1,8 +1,6 @@
 import random
 import time
 
-from faker import Factory
-
 from django.contrib.auth import get_user_model
 from django.core.management.base import BaseCommand
 from django.db.transaction import atomic
@@ -10,6 +8,7 @@ from django.template.defaultfilters import linebreaks_filter
 from django.utils import timezone
 from django.utils.six.moves import range
 
+from faker import Factory
 from misago.categories.models import Category
 from misago.core.management.progressbar import show_progress
 from misago.threads.checksums import update_post_checksum

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

@@ -2,14 +2,13 @@ import random
 import sys
 import time
 
-from faker import Factory
-
 from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
 from django.core.management.base import BaseCommand
 from django.db import IntegrityError
 from django.utils.six.moves import range
 
+from faker import Factory
 from misago.core.management.progressbar import show_progress
 from misago.users.avatars import dynamic, gallery, get_avatar_hash
 from misago.users.models import Rank

+ 2 - 1
misago/markup/parser.py

@@ -1,5 +1,6 @@
-import bleach
 import markdown
+
+import bleach
 from bs4 import BeautifulSoup
 from htmlmin.minify import html_minify
 

+ 2 - 2
misago/markup/pipeline.py

@@ -1,10 +1,10 @@
 from importlib import import_module
 
-from bs4 import BeautifulSoup
-
 from django.conf import settings
 from django.utils import six
 
+from bs4 import BeautifulSoup
+
 
 class MarkupPipeline(object):
     """

+ 40 - 0
misago/threads/api/threads.py

@@ -14,6 +14,7 @@ from misago.readtracker.categoriestracker import read_category
 
 from ..models import Subscription
 from ..moderation import threads as moderation
+from ..permissions.threads import can_start_thread
 from ..subscriptions import make_subscription_aware
 from ..threadtypes import trees_map
 from ..viewmodels.thread import ForumThread
@@ -75,3 +76,42 @@ class ThreadViewSet(ViewSet):
 
         read_category(request.user, category)
         return Response({'detail': 'ok'})
+
+    @list_route(methods=['get'])
+    def editor(self, request):
+        if request.user.is_anonymous():
+            raise PermissionDenied(_("You need to be signed in to post content."))
+
+        # list of categories that allow or contain subcategories that allow new threads
+        available = []
+
+        categories = []
+        for category in Category.objects.filter(pk__in=request.user.acl['browseable_categories']).order_by('-lft'):
+            add_acl(request.user, category)
+
+            post = False
+            if can_start_thread(request.user, category):
+                post = {
+                    'close': bool(category.acl['can_close_threads']),
+                    'hide': bool(category.acl['can_hide_threads']),
+                    'pin': category.acl['can_pin_threads']
+                }
+
+                available.append(category.pk)
+                available.append(category.parent_id)
+            elif category.pk in available:
+                available.append(category.parent_id)
+
+            categories.append({
+                'id': category.pk,
+                'name': category.name,
+                'level': category.level - 1,
+                'post': post
+            })
+
+        cleaned_categories = []
+        for category in reversed(categories):
+            if category['id'] in available:
+                cleaned_categories.append(category)
+
+        return Response(cleaned_categories)

+ 2 - 1
misago/threads/checksums.py

@@ -1,6 +1,7 @@
-from misago.markup import checksums
 from django.utils import six
 
+from misago.markup import checksums
+
 
 def is_post_valid(post):
     valid_checksum = make_post_checksum(post)

+ 25 - 6
misago/threads/permissions/threads.py

@@ -46,6 +46,15 @@ Admin Permissions Forms
 class RolePermissionsForm(forms.Form):
     legend = _("Threads")
 
+    can_download_other_users_attachments = forms.YesNoSwitch(label=_("Can download other users attachments"))
+    max_attachment_size = forms.IntegerField(
+        label=_("Max attached file size (in kb)"),
+        help_text=_("Enter 0 to disable attachments."),
+        initial=500,
+        min_value=0
+    )
+    can_delete_other_users_attachments = forms.YesNoSwitch(label=_("Can delete other users attachments"))
+
     can_see_unapproved_content_lists = forms.YesNoSwitch(
         label=_("Can see unapproved content list"),
         help_text=_('Allows access to "unapproved" tab on threads lists for '
@@ -214,12 +223,20 @@ def change_permissions_form(role):
 ACL Builder
 """
 def build_acl(acl, roles, key_name):
-    acl['can_see_unapproved_content_lists'] = False
-    acl['can_see_reported_content_lists'] = False
-    acl['can_approve_content'] = []
-    acl['can_see_reports'] = []
+    acl.update({
+        'can_download_other_users_attachments': False,
+        'max_attachment_size': 0,
+        'can_delete_other_users_attachments': False,
+        'can_see_unapproved_content_lists': False,
+        'can_see_reported_content_lists': False,
+        'can_approve_content': [],
+        'can_see_reports': [],
+    })
 
     acl = algebra.sum_acls(acl, roles=roles, key=key_name,
+        can_download_other_users_attachments=algebra.greater,
+        max_attachment_size=algebra.greater,
+        can_delete_other_users_attachments=algebra.greater,
         can_see_unapproved_content_lists=algebra.greater,
         can_see_reported_content_lists=algebra.greater
     )
@@ -476,10 +493,12 @@ def allow_start_thread(user, target):
     if user.is_anonymous():
         raise PermissionDenied(_("You have to sign in to start threads."))
 
-    if target.is_closed and not target.acl['can_close_threads']:
+    category_acl = user.acl['categories'].get(target.pk, {})
+
+    if target.is_closed and not category_acl.get('can_close_threads', False):
         raise PermissionDenied(_("This category is closed. You can't start new threads in it."))
 
-    if not user.acl['categories'].get(target.id, {'can_start_threads': False}):
+    if not category_acl.get('can_start_threads', False):
         raise PermissionDenied(_("You don't have permission to start new threads in this category."))
 can_start_thread = return_boolean(allow_start_thread)
 

+ 177 - 0
misago/threads/posting/__init__.py

@@ -0,0 +1,177 @@
+from importlib import import_module
+
+from django.utils import timezone
+
+from misago.conf import settings
+from misago.core import forms
+
+
+class PostingInterrupt(Exception):
+    def __init__(self, message):
+        if not message:
+            raise ValueError("You have to provide PostingInterrupt message.")
+        self.message = message
+
+
+class EditorFormset(object):
+    START = 0
+    REPLY = 1
+    EDIT = 2
+
+    def __init__(self, **kwargs):
+        self.errors = []
+
+        self._forms_list = []
+        self._forms_dict = {}
+
+        self.kwargs = kwargs
+        self.__dict__.update(kwargs)
+
+        self.datetime = timezone.now()
+
+        self.middlewares = []
+        self._load_middlewares()
+
+    @property
+    def is_start_form(self):
+        return self.mode == self.START
+
+    @property
+    def is_reply_form(self):
+        return self.mode == self.REPLY
+
+    @property
+    def is_edit_form(self):
+        return self.mode == self.EDIT
+
+    def _load_middlewares(self):
+        kwargs = self.kwargs.copy()
+        kwargs.update({
+            'datetime': self.datetime,
+            'parsing_result': {},
+        })
+
+        for middleware in settings.MISAGO_POSTING_MIDDLEWARES:
+            module_name = '.'.join(middleware.split('.')[:-1])
+            class_name = middleware.split('.')[-1]
+
+            middleware_module = import_module(module_name)
+            middleware_class = getattr(middleware_module, class_name)
+
+            try:
+                middleware_obj = middleware_class(prefix=middleware, **kwargs)
+                if middleware_obj.use_this_middleware():
+                    self.middlewares.append((middleware, middleware_obj))
+            except PostingInterrupt:
+                raise ValueError("Posting process can only be interrupted during pre_save phase")
+
+    def get_forms_list(self):
+        """return list of forms belonging to formset"""
+        if not self._forms_list:
+            self._build_forms_cache()
+        return self._forms_list
+
+    def get_forms_dict(self):
+        """return list of forms belonging to formset"""
+        if not self._forms_dict:
+            self._build_forms_cache()
+        return self._forms_dict
+
+    def _build_forms_cache(self):
+        try:
+            for middleware, obj in self.middlewares:
+                form = obj.make_form()
+                if form:
+                    self._forms_dict[middleware] = form
+                    self._forms_list.append(form)
+        except PostingInterrupt:
+            raise ValueError("Posting process can only be interrupted during pre_save phase")
+
+    def get_main_forms(self):
+        """return list of main forms"""
+        main_forms = []
+        for form in self.get_forms_list():
+            try:
+                if form.is_main and form.legend:
+                    main_forms.append(form)
+            except AttributeError:
+                pass
+        return main_forms
+
+    def get_supporting_forms(self):
+        """return list of supporting forms"""
+        supporting_forms = {}
+        for form in self.get_forms_list():
+            try:
+                if form.is_supporting:
+                    supporting_forms.setdefault(form.location, []).append(form)
+            except AttributeError:
+                pass
+        return supporting_forms
+
+    def is_valid(self):
+        """validate all forms"""
+        all_forms_valid = True
+        for form in self.get_forms_list():
+            if not form.is_valid():
+                if not form.is_bound:
+                    form_class = form.__class__.__name__
+                    raise ValueError("%s didn't receive any data" % form_class)
+
+                all_forms_valid = False
+                for field_errors in form.errors.as_data().values():
+                    self.errors.extend([unicode(e[0]) for e in field_errors])
+        return all_forms_valid
+
+    def save(self):
+        """change state"""
+        forms_dict = self.get_forms_dict()
+        try:
+            for middleware, obj in self.middlewares:
+                obj.pre_save(forms_dict.get(middleware))
+        except PostingInterrupt as e:
+            raise ValueError("Posting process can only be interrupted from within interrupt_posting method")
+
+        for middleware, obj in self.middlewares:
+            obj.interrupt_posting(forms_dict.get(middleware))
+
+        try:
+            for middleware, obj in self.middlewares:
+                obj.save(forms_dict.get(middleware))
+            for middleware, obj in self.middlewares:
+                obj.post_save(forms_dict.get(middleware))
+        except PostingInterrupt as e:
+            raise ValueError("Posting process can only be interrupted from within interrupt_posting method")
+
+    def update(self):
+        """handle POST that shouldn't result in state change"""
+        forms_dict = self.get_forms_dict()
+        for middleware, obj in self.middlewares:
+            obj.pre_save(forms_dict.get(middleware))
+
+
+class PostingMiddleware(object):
+    """
+    Abstract middleware class
+    """
+    def __init__(self, **kwargs):
+        self.kwargs = kwargs
+        self.__dict__.update(kwargs)
+
+    def use_this_middleware(self):
+        return True
+
+    def make_form(self):
+        pass
+
+    def pre_save(self, form):
+        pass
+
+    def interrupt_posting(self, form):
+        pass
+
+    def save(self, form):
+        pass
+
+    def post_save(self, form):
+        pass

+ 40 - 0
misago/threads/posting/floodprotection.py

@@ -0,0 +1,40 @@
+from datetime import timedelta
+
+from django.conf import settings
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+
+from . import PostingInterrupt, PostingMiddleware
+
+
+MIN_POSTING_PAUSE = 3
+
+
+class FloodProtectionMiddleware(PostingMiddleware):
+    def interrupt_posting(self, form):
+        now = timezone.now()
+
+        if self.user.last_posted_on:
+            previous_post = now - self.user.last_posted_on
+            if previous_post.total_seconds() < MIN_POSTING_PAUSE:
+                raise PostingInterrupt(_("You can't post message so "
+                                         "quickly after previous one."))
+
+        self.user.last_posted_on = timezone.now()
+        self.user.update_fields.append('last_posted_on')
+
+        if settings.MISAGO_HOURLY_POST_LIMIT:
+            cutoff = now - timedelta(hours=24)
+            count_qs = self.user.post_set.filter(posted_on__gte=cutoff)
+            posts_count = count_qs.count()
+            if posts_count > settings.MISAGO_HOURLY_POST_LIMIT:
+                raise PostingInterrupt(_("Your account has excceed "
+                                         "hourly post limit."))
+
+        if settings.MISAGO_DIALY_POST_LIMIT:
+            cutoff = now - timedelta(hours=1)
+            count_qs = self.user.post_set.filter(posted_on__gte=cutoff)
+            posts_count = count_qs.count()
+            if posts_count > settings.MISAGO_DIALY_POST_LIMIT:
+                raise PostingInterrupt(_("Your account has excceed "
+                                         "dialy post limit."))

+ 20 - 0
misago/threads/posting/participants.py

@@ -0,0 +1,20 @@
+from . import START, PostingMiddleware
+from ..forms.posting import ThreadParticipantsForm
+from ..participants import add_owner, add_participant
+
+
+class ThreadParticipantsFormMiddleware(PostingMiddleware):
+    def use_this_middleware(self):
+        return self.is_private and self.mode == START
+
+    def make_form(self):
+        if self.request.method == 'POST':
+            return ThreadParticipantsForm(
+                self.request.POST, user=self.request.user, prefix=self.prefix)
+        else:
+            return ThreadParticipantsForm(prefix=self.prefix)
+
+    def save(self, form):
+        add_owner(self.thread, self.user)
+        for user in form.users_cache:
+            add_participant(self.request, self.thread, user)

+ 23 - 0
misago/threads/posting/recordedit.py

@@ -0,0 +1,23 @@
+from django.db.models import F
+
+from . import EDIT, PostingMiddleware
+
+
+class RecordEditMiddleware(PostingMiddleware):
+    def __init__(self, **kwargs):
+        super(RecordEditMiddleware, self).__init__(**kwargs)
+
+        if self.mode == EDIT:
+            self.original_title = self.thread.title
+            self.original_post = self.post.original
+
+    def save(self, form):
+        if self.mode == EDIT:
+            # record post or thread edit
+            is_title_changed = self.original_title != self.thread.title
+            is_post_changed = self.original_post != self.post.original
+
+            if is_title_changed or is_post_changed:
+                self.post.edits += 1
+                self.post.last_editor_name = self.user.username
+                self.post.update_fields.extend(('edits', 'last_editor_name'))

+ 81 - 0
misago/threads/posting/reply.py

@@ -0,0 +1,81 @@
+from misago.markup import Editor
+
+from . import EDIT, REPLY, START, PostingMiddleware
+from ..checksums import update_post_checksum
+from ..forms.posting import ReplyForm, ThreadForm
+from ..permissions import can_edit_thread
+
+
+class ReplyFormMiddleware(PostingMiddleware):
+    def make_form(self):
+        initial_data = {'title': self.thread.title, 'post': self.post.original}
+
+        if self.mode == EDIT:
+            is_first_post = self.post.id == self.thread.first_post_id
+            if is_first_post and can_edit_thread(self.user, self.thread):
+                FormType = ThreadForm
+            else:
+                FormType = ReplyForm
+        elif self.mode == START:
+            FormType = ThreadForm
+        else:
+            FormType = ReplyForm
+
+        if FormType == ThreadForm:
+            if self.request.method == 'POST':
+                form = FormType(
+                    self.thread, self.post, self.request, self.request.POST)
+            else:
+                form = FormType(
+                    self.thread, self.post, self.request, initial=initial_data)
+        else:
+            if self.request.method == 'POST':
+                form = FormType(
+                    self.post, self.request, self.request.POST)
+            else:
+                form = FormType(
+                    self.post, self.request, initial=initial_data)
+
+        form.post_editor = Editor(form['post'], has_preview=True)
+        return form
+
+    def pre_save(self, form):
+        if form.is_valid():
+            self.parsing_result.update(form.parsing_result)
+
+    def save(self, form):
+        if self.mode == START:
+            self.new_thread(form)
+
+        if self.mode == EDIT:
+            self.edit_post(form)
+        else:
+            self.new_post()
+
+        self.post.updated_on = self.datetime
+        self.post.save()
+
+        update_post_checksum(self.post)
+        self.post.update_fields.append('checksum')
+
+    def new_thread(self, form):
+        self.thread.set_title(form.cleaned_data['title'])
+        self.thread.starter_name = self.user.username
+        self.thread.starter_slug = self.user.slug
+        self.thread.last_poster_name = self.user.username
+        self.thread.last_poster_slug = self.user.slug
+        self.thread.started_on = self.datetime
+        self.thread.last_post_on = self.datetime
+        self.thread.save()
+
+    def edit_post(self, form):
+        if form.cleaned_data.get('title'):
+            self.thread.set_title(form.cleaned_data['title'])
+            self.thread.update_fields.extend(('title', 'slug'))
+
+    def new_post(self):
+        self.post.thread = self.thread
+        self.post.poster = self.user
+        self.post.poster_name = self.user.username
+        self.post.poster_ip = self.request._misago_real_ip
+        self.post.posted_on = self.datetime

+ 40 - 0
misago/threads/posting/savechanges.py

@@ -0,0 +1,40 @@
+from collections import OrderedDict
+
+from . import PostingMiddleware
+
+
+class SaveChangesMiddleware(PostingMiddleware):
+    def __init__(self, **kwargs):
+        super(SaveChangesMiddleware, self).__init__(**kwargs)
+        self.reset_state()
+
+    def reset_state(self):
+        self.user.update_all = False
+        self.forum.update_all = False
+        self.thread.update_all = False
+        self.post.update_all = False
+
+        self.user.update_fields = []
+        self.forum.update_fields = []
+        self.thread.update_fields = []
+        self.post.update_fields = []
+
+    def save_models(self):
+        self.save_model(self.user)
+        self.save_model(self.forum)
+        self.save_model(self.thread)
+        self.save_model(self.post)
+        self.reset_state()
+
+    def save_model(self, model):
+        if model.update_all:
+            model.save()
+        elif model.update_fields:
+            update_fields = list(OrderedDict.fromkeys(model.update_fields))
+            model.save(update_fields=update_fields)
+
+    def save(self, form):
+        self.save_models()
+
+    def post_save(self, form):
+        self.save_models()

+ 33 - 0
misago/threads/posting/threadclose.py

@@ -0,0 +1,33 @@
+from . import START, PostingMiddleware
+from .. import moderation
+from ..forms.posting import ThreadCloseForm
+
+
+class ThreadCloseFormMiddleware(PostingMiddleware):
+    def use_this_middleware(self):
+        if self.forum.acl['can_close_threads']:
+            self.is_closed = self.thread.is_closed
+            return True
+        else:
+            return False
+
+    def make_form(self):
+        if self.request.method == 'POST':
+            return ThreadCloseForm(self.request.POST, prefix=self.prefix)
+        else:
+            initial = {'is_closed': self.is_closed}
+            return ThreadCloseForm(prefix=self.prefix, initial=initial)
+
+    def pre_save(self, form):
+        if form.is_valid() and self.mode == START:
+            if form.cleaned_data.get('is_closed'):
+                self.thread.is_closed = form.cleaned_data.get('is_closed')
+                self.thread.update_fields.append('is_closed')
+
+    def post_save(self, form):
+        if form.is_valid() and self.mode != START:
+            if self.is_closed != form.cleaned_data.get('is_closed'):
+                if self.thread.is_closed:
+                    moderation.open_thread(self.user, self.thread)
+                else:
+                    moderation.close_thread(self.user, self.thread)

+ 50 - 0
misago/threads/posting/threadlabel.py

@@ -0,0 +1,50 @@
+from . import EDIT, START, PostingMiddleware
+from ..forms.posting import ThreadLabelForm
+from ..models import Label
+from ..moderation import label_thread, unlabel_thread
+from ..permissions import can_edit_thread
+
+
+class ThreadLabelFormMiddleware(PostingMiddleware):
+    def use_this_middleware(self):
+        if self.forum.labels and self.forum.acl['can_change_threads_labels']:
+            self.label_id = self.thread.label_id
+
+            if self.mode == START:
+                return True
+
+            if self.mode == EDIT and can_edit_thread(self.user, self.thread):
+                return True
+
+        return False
+
+    def make_form(self):
+        if self.request.method == 'POST':
+            return ThreadLabelForm(self.request.POST, prefix=self.prefix,
+                                    labels=self.forum.labels)
+        else:
+            initial = {'label_id': self.label_id}
+            return ThreadLabelForm(prefix=self.prefix,
+                                    labels=self.forum.labels,
+                                    initial=initial)
+
+    def pre_save(self, form):
+        if form.is_valid() and self.mode == START:
+            if self.label_id != form.cleaned_data.get('label'):
+                if form.cleaned_data.get('label'):
+                    self.thread.label_id = form.cleaned_data.get('label')
+                    self.thread.update_fields.append('label')
+                else:
+                    self.thread.label = None
+                    self.thread.update_fields.append('label')
+
+    def post_save(self, form):
+        if form.is_valid() and self.mode != START:
+            if self.label_id != form.cleaned_data.get('label'):
+                if form.cleaned_data.get('label'):
+                    labels_dict = Label.objects.get_cached_labels_dict()
+                    new_label = labels_dict.get(form.cleaned_data.get('label'))
+                    if new_label:
+                        label_thread(self.user, self.thread, new_label)
+                else:
+                    unlabel_thread(self.user, self.thread)

+ 33 - 0
misago/threads/posting/threadpin.py

@@ -0,0 +1,33 @@
+from . import START, PostingMiddleware
+from .. import moderation
+from ..forms.posting import ThreadPinForm
+
+
+class ThreadPinFormMiddleware(PostingMiddleware):
+    def use_this_middleware(self):
+        if self.forum.acl['can_pin_threads']:
+            self.is_pinned = self.thread.is_pinned
+            return True
+        else:
+            return False
+
+    def make_form(self):
+        if self.request.method == 'POST':
+            return ThreadPinForm(self.request.POST, prefix=self.prefix)
+        else:
+            initial = {'is_pinned': self.is_pinned}
+            return ThreadPinForm(prefix=self.prefix, initial=initial)
+
+    def pre_save(self, form):
+        if form.is_valid() and self.mode == START:
+            if form.cleaned_data.get('is_pinned'):
+                self.thread.is_pinned = form.cleaned_data.get('is_pinned')
+                self.thread.update_fields.append('is_pinned')
+
+    def post_save(self, form):
+        if form.is_valid() and self.mode != START:
+            if self.is_pinned != form.cleaned_data.get('is_pinned'):
+                if self.thread.is_pinned:
+                    moderation.unpin_thread(self.user, self.thread)
+                else:
+                    moderation.pin_thread(self.user, self.thread)

+ 40 - 0
misago/threads/posting/updatestats.py

@@ -0,0 +1,40 @@
+from django.db.models import F
+
+from . import EDIT, REPLY, START, PostingMiddleware
+
+
+class UpdateStatsMiddleware(PostingMiddleware):
+    def save(self, form):
+        self.update_thread()
+        self.update_forum()
+        self.update_user()
+
+    def update_forum(self):
+        if self.mode == START:
+            self.forum.threads = F('threads') + 1
+
+        if self.mode != EDIT:
+            self.forum.set_last_thread(self.thread)
+            self.forum.posts = F('posts') + 1
+            self.forum.update_all = True
+
+    def update_thread(self):
+        if self.mode == START:
+            self.thread.set_first_post(self.post)
+
+        if self.mode != EDIT:
+            self.thread.set_last_post(self.post)
+
+        if self.mode == REPLY:
+            self.thread.replies = F('replies') + 1
+
+        self.thread.update_all = True
+
+    def update_user(self):
+        if self.mode == START:
+            self.user.threads = F('threads') + 1
+            self.user.update_fields.append('threads')
+
+        if self.mode != EDIT:
+            self.user.posts = F('posts') + 1
+            self.user.update_fields.append('posts')

+ 1 - 1
misago/threads/tests/test_gotoviews.py

@@ -1,8 +1,8 @@
 from django.utils import timezone
 
 from misago.acl.testutils import override_acl
-from misago.conf import settings
 from misago.categories.models import Category
+from misago.conf import settings
 from misago.readtracker.threadstracker import make_thread_read_aware, read_thread
 from misago.users.testutils import AuthenticatedUserTestCase
 

+ 1 - 0
misago/threads/tests/test_thread_patch_api.py

@@ -1,4 +1,5 @@
 import json
+
 from django.utils import six
 from django.utils.encoding import smart_str
 

+ 269 - 0
misago/threads/tests/test_threads_editor_api.py

@@ -0,0 +1,269 @@
+import json
+
+from django.core.urlresolvers import reverse
+from django.utils.encoding import smart_str
+
+from misago.acl.testutils import override_acl
+from misago.categories.models import Category
+from misago.users.testutils import AuthenticatedUserTestCase
+
+
+class ThreadsApiTestCase(AuthenticatedUserTestCase):
+    def setUp(self):
+        super(ThreadsApiTestCase, self).setUp()
+
+        self.category = Category.objects.get(slug='first-category')
+        self.api_link = reverse('misago:api:thread-editor')
+
+    def override_acl(self, acl=None):
+        final_acl = {
+            'can_see': 1,
+            'can_browse': 1,
+            'can_see_all_threads': 1,
+            'can_start_threads': 0,
+            'can_reply_threads': 0,
+            'can_edit_threads': 0,
+            'can_edit_posts': 0,
+            'can_hide_own_threads': 0,
+            'can_hide_own_posts': 0,
+            'thread_edit_time': 0,
+            'post_edit_time': 0,
+            'can_hide_threads': 0,
+            'can_hide_posts': 0,
+            'can_protect_posts': 0,
+            'can_move_posts': 0,
+            'can_merge_posts': 0,
+            'can_pin_threads': 0,
+            'can_close_threads': 0,
+            'can_move_threads': 0,
+            'can_merge_threads': 0,
+            'can_split_threads': 0,
+            'can_approve_content': 0,
+            'can_report_content': 0,
+            'can_see_reports': 0,
+            'can_see_posts_likes': 0,
+            'can_like_posts': 0,
+            'can_hide_events': 0,
+        }
+
+        if acl:
+            final_acl.update(acl)
+
+        browseable_categories = []
+        if final_acl['can_browse']:
+            browseable_categories.append(self.category.pk)
+
+        override_acl(self.user, {
+            'browseable_categories': browseable_categories,
+            'categories': {
+                self.category.pk: final_acl
+            }
+        })
+
+    def get_json(self):
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        return json.loads(smart_str(response.content))
+
+    def test_anonymous_user_request(self):
+        """endpoint validates if user is authenticated"""
+        self.logout_user()
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 403)
+
+        response_json = json.loads(smart_str(response.content))
+        self.assertEqual(response_json['detail'], "You need to be signed in to post content.")
+
+    def test_category_visibility_validation(self):
+        """endpoint omits non-browseable categories"""
+        self.override_acl({'can_browse': 0})
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(smart_str(response.content))
+        self.assertEqual(len(response_json), 0)
+
+    def test_category_disallowing_new_threads(self):
+        """endpoint omits category disallowing starting threads"""
+        self.override_acl({
+            'can_start_threads': 0,
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(smart_str(response.content))
+        self.assertEqual(len(response_json), 0)
+
+    def test_category_closed_disallowing_new_threads(self):
+        """endpoint omits closed category"""
+        self.override_acl({
+            'can_start_threads': 2,
+            'can_close_threads': 0,
+        })
+
+        self.category.is_closed = True
+        self.category.save()
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(smart_str(response.content))
+        self.assertEqual(len(response_json), 0)
+
+    def test_category_closed_allowing_new_threads(self):
+        """endpoint adds closed category that allows new threads"""
+        self.override_acl({
+            'can_start_threads': 2,
+            'can_close_threads': 1,
+        })
+
+        self.category.is_closed = True
+        self.category.save()
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(smart_str(response.content))
+        self.assertEqual(response_json[0], {
+            'id': self.category.pk,
+            'name': self.category.name,
+            'level': 0,
+            'post': {
+                'close': True,
+                'hide': False,
+                'pin': 0
+            }
+        })
+
+    def test_category_allowing_new_threads(self):
+        """endpoint adds category that allows new threads"""
+        self.override_acl({
+            'can_start_threads': 2,
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(smart_str(response.content))
+        self.assertEqual(response_json[0], {
+            'id': self.category.pk,
+            'name': self.category.name,
+            'level': 0,
+            'post': {
+                'close': False,
+                'hide': False,
+                'pin': 0
+            }
+        })
+
+    def test_category_allowing_closing_threads(self):
+        """endpoint adds category that allows new closed threads"""
+        self.override_acl({
+            'can_start_threads': 2,
+            'can_close_threads': 1,
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(smart_str(response.content))
+        self.assertEqual(response_json[0], {
+            'id': self.category.pk,
+            'name': self.category.name,
+            'level': 0,
+            'post': {
+                'close': True,
+                'hide': False,
+                'pin': 0
+            }
+        })
+
+    def test_category_allowing_locally_pinned_threads(self):
+        """endpoint adds category that allows locally pinned threads"""
+        self.override_acl({
+            'can_start_threads': 2,
+            'can_pin_threads': 1,
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(smart_str(response.content))
+        self.assertEqual(response_json[0], {
+            'id': self.category.pk,
+            'name': self.category.name,
+            'level': 0,
+            'post': {
+                'close': False,
+                'hide': False,
+                'pin': 1
+            }
+        })
+
+    def test_category_allowing_globally_pinned_threads(self):
+        """endpoint adds category that allows globally pinned threads"""
+        self.override_acl({
+            'can_start_threads': 2,
+            'can_pin_threads': 2,
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(smart_str(response.content))
+        self.assertEqual(response_json[0], {
+            'id': self.category.pk,
+            'name': self.category.name,
+            'level': 0,
+            'post': {
+                'close': False,
+                'hide': False,
+                'pin': 2
+            }
+        })
+
+    def test_category_allowing_hidden_threads(self):
+        """endpoint adds category that allows globally pinned threads"""
+        self.override_acl({
+            'can_start_threads': 2,
+            'can_hide_threads': 1,
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(smart_str(response.content))
+        self.assertEqual(response_json[0], {
+            'id': self.category.pk,
+            'name': self.category.name,
+            'level': 0,
+            'post': {
+                'close': 0,
+                'hide': 1,
+                'pin': 0
+            }
+        })
+
+        self.override_acl({
+            'can_start_threads': 2,
+            'can_hide_threads': 2,
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(smart_str(response.content))
+        self.assertEqual(response_json[0], {
+            'id': self.category.pk,
+            'name': self.category.name,
+            'level': 0,
+            'post': {
+                'close': False,
+                'hide': True,
+                'pin': 0
+            }
+        })

+ 2 - 2
misago/users/avatars/store.py

@@ -1,11 +1,11 @@
 import os
 from hashlib import md5
 
-from django.utils.encoding import force_bytes
-
 from path import Path
 from PIL import Image
 
+from django.utils.encoding import force_bytes
+
 from misago.conf import settings
 
 from .paths import AVATARS_STORE

+ 1 - 0
misago/users/credentialchange.py

@@ -8,6 +8,7 @@ from hashlib import sha256
 from django.conf import settings
 from django.utils import six
 from django.utils.encoding import force_bytes
+
 from misago.core import serializer
 
 

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

@@ -3,6 +3,7 @@
 from __future__ import unicode_literals
 
 from django.db import migrations, models
+
 import misago.users.models.user
 
 

+ 1 - 1
misago/users/tests/test_auth_api.py

@@ -2,8 +2,8 @@ import json
 
 from django.contrib.auth import get_user_model
 from django.core import mail
-from django.utils.encoding import smart_str
 from django.test import TestCase
+from django.utils.encoding import smart_str
 
 from ..models import BAN_USERNAME, Ban
 from ..tokens import make_activation_token, make_password_change_token

+ 1 - 1
misago/users/tests/test_auth_views.py

@@ -2,8 +2,8 @@ import json
 
 from django.contrib.auth import get_user_model
 from django.core.urlresolvers import reverse
-from django.utils.encoding import smart_str
 from django.test import TestCase
+from django.utils.encoding import smart_str
 
 
 class AuthViewsTests(TestCase):

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

@@ -1,8 +1,8 @@
 import json
 
 from django.core.urlresolvers import reverse
-from django.utils.encoding import smart_str
 from django.test import TestCase
+from django.utils.encoding import smart_str
 
 from misago.conf import settings
 

+ 1 - 0
misago/users/tests/test_testutils.py

@@ -2,6 +2,7 @@ import json
 
 from django.core.urlresolvers import reverse
 from django.utils.encoding import smart_str
+
 from ..testutils import AuthenticatedUserTestCase, SuperUserTestCase, UserTestCase
 
 

+ 1 - 1
misago/users/tests/test_useradmin_views.py

@@ -4,8 +4,8 @@ from django.contrib.auth import get_user_model
 from django.core import mail
 from django.core.urlresolvers import reverse
 from django.utils import six
-from django.utils.six.moves import range
 from django.utils.encoding import smart_str
+from django.utils.six.moves import range
 
 from misago.acl.models import Role
 from misago.admin.testutils import AdminTestCase

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

@@ -1,11 +1,11 @@
+import six
+
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.core.urlresolvers import reverse
 from django.shortcuts import render as django_render
 from django.shortcuts import redirect
 
-import six
-
 from misago.core.shortcuts import get_object_or_404, paginate, pagination_dict
 from misago.core.utils import format_plaintext_for_html