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.contrib import messages
 from django.core.paginator import EmptyPage, Paginator
 from django.core.paginator import EmptyPage, Paginator
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
@@ -8,6 +6,7 @@ from django.shortcuts import redirect
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ugettext_lazy as _
 
 
 from misago.core.exceptions import ExplicitFirstPage
 from misago.core.exceptions import ExplicitFirstPage
+from six.moves.urllib.parse import urlencode
 
 
 from .base import AdminView
 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
             # categories perms
             'misago.categories.permissions': {
             'misago.categories.permissions': {
                 'can_see': 1,
                 'can_see': 1,
-                'can_browse': 0,
+                'can_browse': 0
             },
             },
         })
         })
     see_only.save()
     see_only.save()
@@ -34,14 +34,15 @@ def create_default_categories_roles(apps, schema_editor):
             # categories perms
             # categories perms
             'misago.categories.permissions': {
             'misago.categories.permissions': {
                 'can_see': 1,
                 'can_see': 1,
-                'can_browse': 1,
+                'can_browse': 1
             },
             },
 
 
             # threads perms
             # threads perms
             'misago.threads.permissions.threads': {
             'misago.threads.permissions.threads': {
                 'can_see_all_threads': 1,
                 'can_see_all_threads': 1,
                 'can_see_posts_likes': 2,
                 'can_see_posts_likes': 2,
-                'can_like_posts': 1,
+                'can_download_other_users_attachments': 1,
+                'can_like_posts': 1
             },
             },
         })
         })
     read_only.save()
     read_only.save()
@@ -52,7 +53,7 @@ def create_default_categories_roles(apps, schema_editor):
             # categories perms
             # categories perms
             'misago.categories.permissions': {
             'misago.categories.permissions': {
                 'can_see': 1,
                 'can_see': 1,
-                'can_browse': 1,
+                'can_browse': 1
             },
             },
 
 
             # threads perms
             # threads perms
@@ -60,8 +61,10 @@ def create_default_categories_roles(apps, schema_editor):
                 'can_see_all_threads': 1,
                 'can_see_all_threads': 1,
                 'can_reply_threads': 1,
                 'can_reply_threads': 1,
                 'can_edit_posts': 1,
                 'can_edit_posts': 1,
+                'can_download_other_users_attachments': 1,
+                'max_attachment_size': 500,
                 'can_see_posts_likes': 2,
                 'can_see_posts_likes': 2,
-                'can_like_posts': 1,
+                'can_like_posts': 1
             },
             },
         })
         })
     reply_only.save()
     reply_only.save()
@@ -72,7 +75,7 @@ def create_default_categories_roles(apps, schema_editor):
             # categories perms
             # categories perms
             'misago.categories.permissions': {
             'misago.categories.permissions': {
                 'can_see': 1,
                 'can_see': 1,
-                'can_browse': 1,
+                'can_browse': 1
             },
             },
 
 
             # threads perms
             # threads perms
@@ -82,8 +85,10 @@ def create_default_categories_roles(apps, schema_editor):
                 'can_reply_threads': 1,
                 'can_reply_threads': 1,
                 'can_edit_threads': 1,
                 'can_edit_threads': 1,
                 'can_edit_posts': 1,
                 'can_edit_posts': 1,
+                'can_download_other_users_attachments': 1,
+                'max_attachment_size': 500,
                 'can_see_posts_likes': 2,
                 'can_see_posts_likes': 2,
-                'can_like_posts': 1,
+                'can_like_posts': 1
             },
             },
         })
         })
     standard.save()
     standard.save()
@@ -104,8 +109,10 @@ def create_default_categories_roles(apps, schema_editor):
                 'can_reply_threads': 1,
                 'can_reply_threads': 1,
                 'can_edit_threads': 1,
                 'can_edit_threads': 1,
                 'can_edit_posts': 1,
                 'can_edit_posts': 1,
+                'can_download_other_users_attachments': 1,
+                'max_attachment_size': 500,
                 'can_see_posts_likes': 2,
                 'can_see_posts_likes': 2,
-                'can_like_posts': 1,
+                'can_like_posts': 1
             },
             },
         })
         })
     standard_with_polls.save()
     standard_with_polls.save()
@@ -116,7 +123,7 @@ def create_default_categories_roles(apps, schema_editor):
             # categories perms
             # categories perms
             'misago.categories.permissions': {
             'misago.categories.permissions': {
                 'can_see': 1,
                 'can_see': 1,
-                'can_browse': 1,
+                'can_browse': 1
             },
             },
 
 
             # threads perms
             # threads perms
@@ -142,11 +149,14 @@ def create_default_categories_roles(apps, schema_editor):
                 'can_merge_threads': 1,
                 'can_merge_threads': 1,
                 'can_split_threads': 1,
                 'can_split_threads': 1,
                 'can_approve_content': 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_see_posts_likes': 2,
                 'can_like_posts': 1,
                 'can_like_posts': 1,
+                'can_report_content': 1,
                 'can_see_reports': 1,
                 'can_see_reports': 1,
-                'can_hide_events': 2,
+                'can_hide_events': 2
             },
             },
         })
         })
     moderator.save()
     moderator.save()

+ 1 - 0
misago/conf/hydrators.py

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

+ 2 - 2
misago/core/shortcuts.py

@@ -1,10 +1,10 @@
 from collections import OrderedDict
 from collections import OrderedDict
 
 
+import six
+
 from django.http import Http404
 from django.http import Http404
 from django.shortcuts import *  # noqa
 from django.shortcuts import *  # noqa
 
 
-import six
-
 
 
 def paginate(object_list, page, per_page, orphans=0,
 def paginate(object_list, page, per_page, orphans=0,
              allow_empty_first_page=True,
              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 import template
 from django.template import Context
 from django.template import Context
 from django.template.loader import get_template
 from django.template.loader import get_template
 
 
+from crispy_forms.templatetags import crispy_forms_field, crispy_forms_filters
+
 
 
 register = template.Library()
 register = template.Library()
 
 

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

@@ -1,5 +1,6 @@
 from django.test import TestCase
 from django.test import TestCase
 from django.utils.six.moves import range
 from django.utils.six.moves import range
+
 from ..apipaginator import ApiPaginator
 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.test import TestCase
+from django.utils.six.moves import range
 
 
 from .. import serializer
 from .. import serializer
 
 

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

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

+ 1 - 2
misago/core/utils.py

@@ -1,5 +1,6 @@
 from datetime import timedelta
 from datetime import timedelta
 
 
+import six
 from unidecode import unidecode
 from unidecode import unidecode
 
 
 from django.core.urlresolvers import resolve, reverse
 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 ugettext_lazy as _
 from django.utils.translation import ungettext_lazy
 from django.utils.translation import ungettext_lazy
 
 
-import six
-
 
 
 def slugify(string):
 def slugify(string):
     string = six.text_type(string)
     string = six.text_type(string)

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

@@ -2,12 +2,11 @@ import random
 import sys
 import sys
 from datetime import timedelta
 from datetime import timedelta
 
 
-from faker import Factory
-
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.six.moves import range
 from django.utils.six.moves import range
 
 
+from faker import Factory
 from misago.core.management.progressbar import show_progress
 from misago.core.management.progressbar import show_progress
 from misago.users.models import BAN_EMAIL, BAN_IP, BAN_USERNAME, Ban
 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 sys
 import time
 import time
 
 
-from django.utils.six.moves import range
-from faker import Factory
-
 from django.core.management.base import BaseCommand
 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.acl import version as acl_version
 from misago.categories.models import Category, RoleCategoryACL
 from misago.categories.models import Category, RoleCategoryACL
 from misago.core.management.progressbar import show_progress
 from misago.core.management.progressbar import show_progress

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

@@ -1,8 +1,6 @@
 import random
 import random
 import time
 import time
 
 
-from faker import Factory
-
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
 from django.db.transaction import atomic
 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 import timezone
 from django.utils.six.moves import range
 from django.utils.six.moves import range
 
 
+from faker import Factory
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.core.management.progressbar import show_progress
 from misago.core.management.progressbar import show_progress
 from misago.threads.checksums import update_post_checksum
 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 sys
 import time
 import time
 
 
-from faker import Factory
-
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
 from django.db import IntegrityError
 from django.db import IntegrityError
 from django.utils.six.moves import range
 from django.utils.six.moves import range
 
 
+from faker import Factory
 from misago.core.management.progressbar import show_progress
 from misago.core.management.progressbar import show_progress
 from misago.users.avatars import dynamic, gallery, get_avatar_hash
 from misago.users.avatars import dynamic, gallery, get_avatar_hash
 from misago.users.models import Rank
 from misago.users.models import Rank

+ 2 - 1
misago/markup/parser.py

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

+ 2 - 2
misago/markup/pipeline.py

@@ -1,10 +1,10 @@
 from importlib import import_module
 from importlib import import_module
 
 
-from bs4 import BeautifulSoup
-
 from django.conf import settings
 from django.conf import settings
 from django.utils import six
 from django.utils import six
 
 
+from bs4 import BeautifulSoup
+
 
 
 class MarkupPipeline(object):
 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 ..models import Subscription
 from ..moderation import threads as moderation
 from ..moderation import threads as moderation
+from ..permissions.threads import can_start_thread
 from ..subscriptions import make_subscription_aware
 from ..subscriptions import make_subscription_aware
 from ..threadtypes import trees_map
 from ..threadtypes import trees_map
 from ..viewmodels.thread import ForumThread
 from ..viewmodels.thread import ForumThread
@@ -75,3 +76,42 @@ class ThreadViewSet(ViewSet):
 
 
         read_category(request.user, category)
         read_category(request.user, category)
         return Response({'detail': 'ok'})
         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 django.utils import six
 
 
+from misago.markup import checksums
+
 
 
 def is_post_valid(post):
 def is_post_valid(post):
     valid_checksum = make_post_checksum(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):
 class RolePermissionsForm(forms.Form):
     legend = _("Threads")
     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(
     can_see_unapproved_content_lists = forms.YesNoSwitch(
         label=_("Can see unapproved content list"),
         label=_("Can see unapproved content list"),
         help_text=_('Allows access to "unapproved" tab on threads lists for '
         help_text=_('Allows access to "unapproved" tab on threads lists for '
@@ -214,12 +223,20 @@ def change_permissions_form(role):
 ACL Builder
 ACL Builder
 """
 """
 def build_acl(acl, roles, key_name):
 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,
     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_unapproved_content_lists=algebra.greater,
         can_see_reported_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():
     if user.is_anonymous():
         raise PermissionDenied(_("You have to sign in to start threads."))
         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."))
         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."))
         raise PermissionDenied(_("You don't have permission to start new threads in this category."))
 can_start_thread = return_boolean(allow_start_thread)
 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 django.utils import timezone
 
 
 from misago.acl.testutils import override_acl
 from misago.acl.testutils import override_acl
-from misago.conf import settings
 from misago.categories.models import Category
 from misago.categories.models import Category
+from misago.conf import settings
 from misago.readtracker.threadstracker import make_thread_read_aware, read_thread
 from misago.readtracker.threadstracker import make_thread_read_aware, read_thread
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 

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

@@ -1,4 +1,5 @@
 import json
 import json
+
 from django.utils import six
 from django.utils import six
 from django.utils.encoding import smart_str
 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
 import os
 from hashlib import md5
 from hashlib import md5
 
 
-from django.utils.encoding import force_bytes
-
 from path import Path
 from path import Path
 from PIL import Image
 from PIL import Image
 
 
+from django.utils.encoding import force_bytes
+
 from misago.conf import settings
 from misago.conf import settings
 
 
 from .paths import AVATARS_STORE
 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.conf import settings
 from django.utils import six
 from django.utils import six
 from django.utils.encoding import force_bytes
 from django.utils.encoding import force_bytes
+
 from misago.core import serializer
 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 __future__ import unicode_literals
 
 
 from django.db import migrations, models
 from django.db import migrations, models
+
 import misago.users.models.user
 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.contrib.auth import get_user_model
 from django.core import mail
 from django.core import mail
-from django.utils.encoding import smart_str
 from django.test import TestCase
 from django.test import TestCase
+from django.utils.encoding import smart_str
 
 
 from ..models import BAN_USERNAME, Ban
 from ..models import BAN_USERNAME, Ban
 from ..tokens import make_activation_token, make_password_change_token
 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.contrib.auth import get_user_model
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
-from django.utils.encoding import smart_str
 from django.test import TestCase
 from django.test import TestCase
+from django.utils.encoding import smart_str
 
 
 
 
 class AuthViewsTests(TestCase):
 class AuthViewsTests(TestCase):

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

@@ -1,8 +1,8 @@
 import json
 import json
 
 
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
-from django.utils.encoding import smart_str
 from django.test import TestCase
 from django.test import TestCase
+from django.utils.encoding import smart_str
 
 
 from misago.conf import settings
 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.core.urlresolvers import reverse
 from django.utils.encoding import smart_str
 from django.utils.encoding import smart_str
+
 from ..testutils import AuthenticatedUserTestCase, SuperUserTestCase, UserTestCase
 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 import mail
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 from django.utils import six
 from django.utils import six
-from django.utils.six.moves import range
 from django.utils.encoding import smart_str
 from django.utils.encoding import smart_str
+from django.utils.six.moves import range
 
 
 from misago.acl.models import Role
 from misago.acl.models import Role
 from misago.admin.testutils import AdminTestCase
 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.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 from django.shortcuts import render as django_render
 from django.shortcuts import render as django_render
 from django.shortcuts import redirect
 from django.shortcuts import redirect
 
 
-import six
-
 from misago.core.shortcuts import get_object_or_404, paginate, pagination_dict
 from misago.core.shortcuts import get_object_or_404, paginate, pagination_dict
 from misago.core.utils import format_plaintext_for_html
 from misago.core.utils import format_plaintext_for_html