Browse Source

some thread PATCH moderation actions, threads with unapproved content list, small threads perms cleanup

Rafał Pitoń 9 years ago
parent
commit
64f9d64912

+ 0 - 6
docs/developers/settings.rst

@@ -236,12 +236,6 @@ Date format used by Misago ``compact_date`` filter for dates in past years.
 Expects standard Django date format, documented `here <https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date>`_
 
 
-MISAGO_CONTENT_COUNTING_FREQUENCY
----------------------------------
-
-Maximum allowed age of content counts cache in minutes. The lower the number, the more accurate will be numbers of new and unread threads in navbar, but greater the stress on database.
-
-
 MISAGO_DIALY_POST_LIMIT
 -----------------------
 

+ 1 - 2
misago/categories/migrations/0003_categories_roles.py

@@ -128,14 +128,13 @@ def create_default_categories_roles(apps, schema_editor):
                 'can_protect_posts': 1,
                 'can_move_posts': 1,
                 'can_merge_posts': 1,
-                'can_change_threads_labels': 2,
                 'can_announce_threads': 1,
                 'can_pin_threads': 2,
                 'can_close_threads': 1,
                 'can_move_threads': 1,
                 'can_merge_threads': 1,
                 'can_split_threads': 1,
-                'can_review_moderated_content': 1,
+                'can_approve_content': 1,
                 'can_report_content': 1,
                 'can_see_reports': 1,
                 'can_hide_events': 2,

+ 2 - 2
misago/categories/models.py

@@ -120,7 +120,7 @@ class Category(MPTTModel):
         return super(Category, self).delete(*args, **kwargs)
 
     def synchronize(self):
-        self.threads = self.thread_set.filter(is_moderated=False).count()
+        self.threads = self.thread_set.filter(is_unapproved=False).count()
 
         if self.threads:
             replies_sum = self.thread_set.aggregate(models.Sum('replies'))
@@ -129,7 +129,7 @@ class Category(MPTTModel):
             self.posts = 0
 
         if self.threads:
-            last_thread_qs = self.thread_set.filter(is_moderated=False)
+            last_thread_qs = self.thread_set.filter(is_unapproved=False)
             last_thread = last_thread_qs.order_by('-last_post_on')[:1][0]
             self.set_last_thread(last_thread)
         else:

+ 9 - 9
misago/categories/tests/test_category_model.py

@@ -76,16 +76,16 @@ class CategoryModelTests(MisagoTestCase):
 
         thread = self.create_thread()
         hidden = self.create_thread()
-        moderated = self.create_thread()
+        unapproved = self.create_thread()
 
         self.category.synchronize()
         self.assertEqual(self.category.threads, 3)
         self.assertEqual(self.category.posts, 3)
-        self.assertEqual(self.category.last_thread, moderated)
+        self.assertEqual(self.category.last_thread, unapproved)
 
-        moderated.is_moderated = True
-        moderated.post_set.update(is_moderated=True)
-        moderated.save()
+        unapproved.is_unapproved = True
+        unapproved.post_set.update(is_unapproved=True)
+        unapproved.save()
 
         self.category.synchronize()
         self.assertEqual(self.category.threads, 2)
@@ -101,14 +101,14 @@ class CategoryModelTests(MisagoTestCase):
         self.assertEqual(self.category.posts, 2)
         self.assertEqual(self.category.last_thread, hidden)
 
-        moderated.is_moderated = False
-        moderated.post_set.update(is_moderated=False)
-        moderated.save()
+        unapproved.is_unapproved = False
+        unapproved.post_set.update(is_unapproved=False)
+        unapproved.save()
 
         self.category.synchronize()
         self.assertEqual(self.category.threads, 3)
         self.assertEqual(self.category.posts, 3)
-        self.assertEqual(self.category.last_thread, moderated)
+        self.assertEqual(self.category.last_thread, unapproved)
 
     def test_delete_content(self):
         """delete_content empties category"""

+ 0 - 4
misago/conf/defaults.py

@@ -328,10 +328,6 @@ MISAGO_USERS_PER_PAGE = 12
 # there will be confidered fresh for "Threads with unread replies" list
 MISAGO_FRESH_CONTENT_PERIOD = 40
 
-# Number of minutes between updates of new content counts like new threads,
-# unread replies or moderated threads/posts
-MISAGO_CONTENT_COUNTING_FREQUENCY = 5
-
 
 # X-Sendfile
 # X-Sendfile is feature provided by Http servers that allows web apps to

+ 5 - 5
misago/faker/management/commands/createfakethreads.py

@@ -47,7 +47,7 @@ class Command(BaseCommand):
                 category = random.choice(categories)
                 user = User.objects.order_by('?')[:1][0]
 
-                thread_is_moderated = random.randint(0, 100) > 90
+                thread_is_unapproved = random.randint(0, 100) > 90
                 thread_is_hidden = random.randint(0, 100) > 90
                 thread_is_closed = random.randint(0, 100) > 90
 
@@ -60,7 +60,7 @@ class Command(BaseCommand):
                     last_poster_name='-',
                     last_poster_slug='-',
                     replies=0,
-                    is_moderated=thread_is_moderated,
+                    is_unapproved=thread_is_unapproved,
                     is_hidden=thread_is_hidden,
                     is_closed=thread_is_closed
                 )
@@ -103,8 +103,8 @@ class Command(BaseCommand):
                     user = User.objects.order_by('?')[:1][0]
                     fake_message = "\n\n".join(fake.paragraphs())
 
-                    is_moderated = random.randint(0, 100) > 97
-                    if not is_moderated:
+                    is_unapproved = random.randint(0, 100) > 97
+                    if not is_unapproved:
                         is_hidden = random.randint(0, 100) > 97
                     else:
                         is_hidden = False
@@ -118,7 +118,7 @@ class Command(BaseCommand):
                         original=fake_message,
                         parsed=linebreaks_filter(fake_message),
                         is_hidden=is_hidden,
-                        is_moderated=is_moderated,
+                        is_unapproved=is_unapproved,
                         posted_on=datetime,
                         updated_on=datetime
                     )

+ 4 - 3
misago/readtracker/tests/test_readtracker.py

@@ -175,12 +175,13 @@ class ThreadsTrackerTests(ReadTrackerTests):
 
         self.thread = self.post_thread(timezone.now() - timedelta(days=10))
 
-    def reply_thread(self, is_hidden=False, is_moderated=False):
+    def reply_thread(self, is_hidden=False, is_unapproved=False):
         self.post = testutils.reply_thread(
             thread=self.thread,
             is_hidden=is_hidden,
-            is_moderated=is_moderated,
-            posted_on=timezone.now())
+            is_unapproved=is_unapproved,
+            posted_on=timezone.now()
+        )
         return self.post
 
     def test_thread_read_for_guest(self):

+ 2 - 1
misago/readtracker/threadstracker.py

@@ -145,7 +145,8 @@ def sync_record(user, thread, last_read_reply):
         user.threadread_set.create(
             category=thread.category,
             thread=thread,
-            last_read_on=last_read_reply.posted_on)
+            last_read_on=last_read_reply.posted_on
+        )
         signals.thread_tracked.send(sender=user, thread=thread)
         notification_triggers.append('see_thread_%s' % thread.pk)
 

+ 4 - 0
misago/templates/misago/threadslist/category.html

@@ -34,7 +34,11 @@
 {% block page-header %}
 <div class="page-header {{ user.is_authenticated|iftrue:"tabbed" }}">
   <div class="container">
+    {% if list_type == 'unapproved' and category.parent_id in user.acl.can_approve_content %}
+    <a href="{{ category.parent.get_absolute_url }}unapproved/" class="btn btn-default btn-aligned btn-icon btn-go-back pull-left">
+    {% else %}
     <a href="{{ category.parent.get_absolute_url }}{% if list_type != 'all' %}{{ list_type }}/{% endif %}" class="btn btn-default btn-aligned btn-icon btn-go-back pull-left">
+    {% endif %}
       <span class="material-icon">
         keyboard_arrow_left
       </span>

+ 8 - 0
misago/templates/misago/threadslist/tabs.html

@@ -32,6 +32,14 @@
           <span class="hidden-md hidden-lg">{% trans "Subscribed threads" %}</span>
         </a>
       </li>
+      {% if category.pk in user.acl.can_approve_content %}
+        <li{% if list_type == 'unapproved' %} class="active"{% endif %}>
+          <a href="{{ category.get_absolute_url }}unapproved/">
+            <span class="hidden-xs hidden-sm">{% trans "Unapproved" %}</span>
+            <span class="hidden-md hidden-lg">{% trans "Unapproved content" %}</span>
+          </a>
+        </li>
+      {% endif %}
     </ul>
   </div>
 </div>

+ 5 - 1
misago/threads/api/threadendpoints/list.py

@@ -22,6 +22,7 @@ LIST_TYPES = (
     'new',
     'unread',
     'subscribed',
+    'unapproved',
 )
 
 
@@ -104,7 +105,10 @@ class ThreadsListEndpoint(ThreadsListMixin, BaseListEndpoint):
 
             for category in categories:
                 if category.pk == category_id:
-                    break;
+                    if category.level:
+                        break;
+                    else:
+                        raise Http404() # disallow root category access
             else:
                 raise Http404()
 

+ 21 - 6
misago/threads/migrations/0001_initial.py

@@ -39,7 +39,7 @@ class Migration(migrations.Migration):
                 ('hidden_on', models.DateTimeField(default=django.utils.timezone.now)),
                 ('has_reports', models.BooleanField(default=False)),
                 ('has_open_reports', models.BooleanField(default=False)),
-                ('is_moderated', models.BooleanField(default=False, db_index=True)),
+                ('is_unapproved', models.BooleanField(default=False, db_index=True)),
                 ('is_hidden', models.BooleanField(default=False)),
                 ('is_protected', models.BooleanField(default=False)),
                 ('category', models.ForeignKey(to='misago_categories.Category')),
@@ -70,7 +70,7 @@ class Migration(migrations.Migration):
                 ('replies', models.PositiveIntegerField(default=0, db_index=True)),
                 ('has_reported_posts', models.BooleanField(default=False)),
                 ('has_open_reports', models.BooleanField(default=False)),
-                ('has_moderated_posts', models.BooleanField(default=False)),
+                ('has_unapproved_posts', models.BooleanField(default=False)),
                 ('has_hidden_posts', models.BooleanField(default=False)),
                 ('has_events', models.BooleanField(default=False)),
                 ('started_on', models.DateTimeField(db_index=True)),
@@ -81,7 +81,7 @@ class Migration(migrations.Migration):
                 ('last_poster_slug', models.CharField(max_length=255, null=True, blank=True)),
                 ('weight', models.PositiveIntegerField(default=0)),
                 ('is_poll', models.BooleanField(default=False)),
-                ('is_moderated', models.BooleanField(default=False, db_index=True)),
+                ('is_unapproved', models.BooleanField(default=False, db_index=True)),
                 ('is_hidden', models.BooleanField(default=False)),
                 ('is_closed', models.BooleanField(default=False)),
             ],
@@ -142,15 +142,30 @@ class Migration(migrations.Migration):
             condition='has_reported_posts = TRUE',
         ),
         CreatePartialIndex(
-            field='Thread.has_moderated_posts',
-            index_name='misago_thread_has_moderated_posts_partial',
-            condition='has_moderated_posts = TRUE',
+            field='Thread.has_unapproved_posts',
+            index_name='misago_thread_has_unapproved_posts_partial',
+            condition='has_unapproved_posts = TRUE',
         ),
         CreatePartialIndex(
             field='Thread.is_hidden',
             index_name='misago_thread_is_hidden_partial',
             condition='is_hidden = FALSE',
         ),
+        CreatePartialIndex(
+            field='Thread.weight',
+            index_name='misago_thread_is_pinned_globally_partial',
+            condition='weight = 2',
+        ),
+        CreatePartialIndex(
+            field='Thread.weight',
+            index_name='misago_thread_is_pinned_locally_partial',
+            condition='weight = 1',
+        ),
+        CreatePartialIndex(
+            field='Thread.weight',
+            index_name='misago_thread_is_unpinned_partial',
+            condition='weight = 0',
+        ),
         migrations.AddField(
             model_name='post',
             name='thread',

+ 11 - 0
misago/threads/mixins/threadslists.py

@@ -22,6 +22,8 @@ def filter_threads_queryset(user, categories, list_type, queryset):
     elif list_type == 'subscribed':
         subscribed_threads = user.subscription_set.values('thread_id')
         return queryset.filter(id__in=subscribed_threads)
+    elif list_type == 'unapproved':
+        return queryset.filter(has_unapproved_posts=True)
     else:
         # grab cutoffs for categories
         cutoff_date = timezone.now() - timedelta(
@@ -112,6 +114,15 @@ class ThreadsListMixin(object):
                 raise PermissionDenied(_("You have to sign in to see list of "
                                          "threads you are subscribing."))
 
+            if list_type == 'unapproved':
+                raise PermissionDenied(_("You have to sign in to see list of "
+                                         "threads with unapproved posts."))
+        else:
+            if (list_type == 'unapproved' and
+                    category.pk not in request.user.acl['can_approve_content']):
+                raise PermissionDenied(_("You don't have permission to "
+                                         "approve content in this category."))
+
     def get_categories(self, request):
         return [Category.objects.root_category()] + list(
             Category.objects.all_categories().filter(

+ 1 - 1
misago/threads/models/post.py

@@ -44,7 +44,7 @@ class Post(models.Model):
 
     has_reports = models.BooleanField(default=False)
     has_open_reports = models.BooleanField(default=False)
-    is_moderated = models.BooleanField(default=False, db_index=True)
+    is_unapproved = models.BooleanField(default=False, db_index=True)
     is_hidden = models.BooleanField(default=False)
     is_protected = models.BooleanField(default=False)
 

+ 8 - 8
misago/threads/models/thread.py

@@ -33,7 +33,7 @@ class Thread(models.Model):
     replies = models.PositiveIntegerField(default=0, db_index=True)
     has_reported_posts = models.BooleanField(default=False)
     has_open_reports = models.BooleanField(default=False)
-    has_moderated_posts = models.BooleanField(default=False)
+    has_unapproved_posts = models.BooleanField(default=False)
     has_hidden_posts = models.BooleanField(default=False)
     has_events = models.BooleanField(default=False)
     started_on = models.DateTimeField(db_index=True)
@@ -79,7 +79,7 @@ class Thread(models.Model):
     weight = models.PositiveIntegerField(default=THREAD_WEIGHT_DEFAULT)
 
     is_poll = models.BooleanField(default=False)
-    is_moderated = models.BooleanField(default=False, db_index=True)
+    is_unapproved = models.BooleanField(default=False, db_index=True)
     is_hidden = models.BooleanField(default=False)
     is_closed = models.BooleanField(default=False)
 
@@ -123,7 +123,7 @@ class Thread(models.Model):
         move_thread.send(sender=self)
 
     def synchronize(self):
-        self.replies = self.post_set.filter(is_moderated=False).count()
+        self.replies = self.post_set.filter(is_unapproved=False).count()
         if self.replies > 0:
             self.replies -= 1
 
@@ -136,8 +136,8 @@ class Thread(models.Model):
         else:
             self.has_open_reports = False
 
-        moderated_post_qs = self.post_set.filter(is_moderated=True)
-        self.has_moderated_posts = moderated_post_qs.exists()
+        unapproved_post_qs = self.post_set.filter(is_unapproved=True)
+        self.has_unapproved_posts = unapproved_post_qs.exists()
 
         hidden_post_qs = self.post_set.filter(is_hidden=True)[:1]
         self.has_hidden_posts = hidden_post_qs.exists()
@@ -147,10 +147,10 @@ class Thread(models.Model):
         first_post = self.post_set.order_by('id')[:1][0]
         self.set_first_post(first_post)
 
-        self.is_moderated = first_post.is_moderated
+        self.is_unapproved = first_post.is_unapproved
         self.is_hidden = first_post.is_hidden
 
-        last_post_qs = self.post_set.filter(is_moderated=False).order_by('-id')
+        last_post_qs = self.post_set.filter(is_unapproved=False).order_by('-id')
         last_post = last_post_qs[:1]
         if last_post:
             self.set_last_post(last_post[0])
@@ -187,7 +187,7 @@ class Thread(models.Model):
         else:
             self.starter_slug = slugify(post.poster_name)
 
-        self.is_moderated = post.is_moderated
+        self.is_unapproved = post.is_unapproved
         self.is_hidden = post.is_hidden
 
     def set_last_post(self, post):

+ 3 - 3
misago/threads/moderation/posts.py

@@ -6,9 +6,9 @@ from misago.threads.moderation.exceptions import ModerationError
 
 
 def approve_post(user, post):
-    if post.is_moderated:
-        post.is_moderated = False
-        post.save(update_fields=['is_moderated'])
+    if post.is_unapproved:
+        post.is_unapproved = False
+        post.save(update_fields=['is_unapproved'])
         return True
     else:
         return False

+ 4 - 4
misago/threads/moderation/threads.py

@@ -75,15 +75,15 @@ def merge_thread(user, thread, other_thread):
 
 @atomic
 def approve_thread(user, thread):
-    if thread.is_moderated:
+    if thread.is_unapproved:
         message = _("%(user)s approved thread.")
         record_event(user, thread, "check", message, {'user': user})
 
         thread.is_closed = False
-        thread.first_post.is_moderated = False
-        thread.first_post.save(update_fields=['is_moderated'])
+        thread.first_post.is_unapproved = False
+        thread.first_post.save(update_fields=['is_unapproved'])
         thread.synchronize()
-        thread.save(update_fields=['has_events', 'is_moderated'])
+        thread.save(update_fields=['has_events', 'is_unapproved'])
         return True
     else:
         return False

+ 8 - 8
misago/threads/permissions/privatethreads.py

@@ -98,7 +98,7 @@ def build_acl(acl, roles, key_name):
     private_category = Category.objects.private_threads()
 
     if new_acl['can_moderate_private_threads']:
-        new_acl['can_review_moderated_content'].append(private_category.pk)
+        new_acl['can_approve_content'].append(private_category.pk)
 
     category_acl = {
         'can_see': 1,
@@ -118,7 +118,7 @@ def build_acl(acl, roles, key_name):
         'can_protect_posts': 0,
         'can_merge_posts': 0,
         'can_close_threads': 0,
-        'can_review_moderated_content': 0,
+        'can_approve_content': 0,
         'can_report_content': new_acl['can_report_private_threads'],
         'can_see_reports': 0,
         'can_hide_events': 0,
@@ -134,7 +134,7 @@ def build_acl(acl, roles, key_name):
             'can_merge_posts': 1,
             'can_see_reports': 1,
             'can_see_reports': 1,
-            'can_review_moderated_content': 1,
+            'can_approve_content': 1,
             'can_hide_events': 2,
         })
 
@@ -156,18 +156,18 @@ can_use_private_threads = return_boolean(allow_use_private_threads)
 
 
 def allow_see_private_thread(user, target):
-    can_see_moderated = user.acl.get('can_moderate_private_threads')
-    can_see_moderated = can_see_moderated and target.has_reported_posts
+    can_see_unapproved = user.acl.get('can_moderate_private_threads')
+    can_see_unapproved = can_see_unapproved and target.has_reported_posts
     can_see_participating = user in [p.user for p in target.participants_list]
 
-    if not (can_see_participating or can_see_moderated):
+    if not (can_see_participating or can_see_unapproved):
         raise Http404()
 can_see_private_thread = return_boolean(allow_see_private_thread)
 
 
 def allow_see_private_post(user, target):
-    can_see_moderated = user.acl.get('can_moderate_private_threads')
-    if not (can_see_moderated and target.thread.has_reported_posts):
+    can_see_unapproved = user.acl.get('can_moderate_private_threads')
+    if not (can_see_unapproved and target.thread.has_reported_posts):
         for participant in target.thread.participants_list:
             if participant.user == user and participant.is_removed:
                 if post.posted_on > target.last_post_on:

+ 48 - 31
misago/threads/permissions/threads.py

@@ -143,9 +143,9 @@ class PermissionsForm(forms.Form):
     can_move_threads = forms.YesNoSwitch(label=_("Can move threads"))
     can_merge_threads = forms.YesNoSwitch(label=_("Can merge threads"))
     can_split_threads = forms.YesNoSwitch(label=_("Can split threads"))
-    can_review_moderated_content = forms.YesNoSwitch(
-        label=_("Can review moderated content"),
-        help_text=_("Will see and be able to accept moderated content.")
+    can_approve_content = forms.YesNoSwitch(
+        label=_("Can approve content"),
+        help_text=_("Will be able to see and approve unapproved content.")
     )
     can_report_content = forms.YesNoSwitch(label=_("Can report posts"))
     can_see_reports = forms.YesNoSwitch(label=_("Can see reports"))
@@ -173,19 +173,36 @@ def change_permissions_form(role):
 ACL Builder
 """
 def build_acl(acl, roles, key_name):
-    acl['can_review_moderated_content'] = []
+    acl['can_approve_content'] = []
+    acl['can_pin_threads'] = []
+    acl['can_close_threads'] = []
     acl['can_see_reports'] = []
+
     categories_roles = get_categories_roles(roles)
+    categories = list(Category.objects.all_categories(include_root=True))
+
+    approve_in_categories = []
 
-    for category in Category.objects.all_categories():
+    for category in categories:
         category_acl = acl['categories'].get(category.pk, {'can_browse': 0})
         if category_acl['can_browse']:
             acl['categories'][category.pk] = build_category_acl(
                 category_acl, category, categories_roles, key_name)
-            if acl['categories'][category.pk]['can_review_moderated_content']:
-                acl['can_review_moderated_content'].append(category.pk)
-            if acl['categories'][category.pk]['can_see_reports']:
+            if acl['categories'][category.pk].get('can_pin_threads'):
+                acl['can_pin_threads'].append(category.pk)
+            if acl['categories'][category.pk].get('can_close_threads'):
+                acl['can_close_threads'].append(category.pk)
+            if acl['categories'][category.pk].get('can_see_reports'):
                 acl['can_see_reports'].append(category.pk)
+
+            if acl['categories'][category.pk].get('can_approve_content'):
+                approve_in_categories.append(category)
+
+    for category in categories:
+        for sub in approve_in_categories:
+            if category.has_child(sub) or category == sub:
+                acl['can_approve_content'].append(category.pk)
+
     return acl
 
 
@@ -212,7 +229,7 @@ def build_category_acl(acl, category, categories_roles, key_name):
         'can_move_threads': 0,
         'can_merge_threads': 0,
         'can_split_threads': 0,
-        'can_review_moderated_content': 0,
+        'can_approve_content': 0,
         'can_report_content': 0,
         'can_see_reports': 0,
         'can_hide_events': 0,
@@ -239,7 +256,7 @@ def build_category_acl(acl, category, categories_roles, key_name):
         can_move_threads=algebra.greater,
         can_merge_threads=algebra.greater,
         can_split_threads=algebra.greater,
-        can_review_moderated_content=algebra.greater,
+        can_approve_content=algebra.greater,
         can_report_content=algebra.greater,
         can_see_reports=algebra.greater,
         can_hide_events=algebra.greater,
@@ -274,7 +291,7 @@ def add_acl_to_category(user, category):
         'can_move_threads': 0,
         'can_merge_threads': 0,
         'can_split_threads': 0,
-        'can_review_moderated_content': 0,
+        'can_approve_content': 0,
         'can_report_content': 0,
         'can_see_reports': 0,
         'can_hide_events': 0,
@@ -303,7 +320,7 @@ def add_acl_to_category(user, category):
             can_move_threads=algebra.greater,
             can_merge_threads=algebra.greater,
             can_split_threads=algebra.greater,
-            can_review_moderated_content=algebra.greater,
+            can_approve_content=algebra.greater,
             can_report_content=algebra.greater,
             can_see_reports=algebra.greater,
             can_hide_events=algebra.greater,
@@ -322,7 +339,7 @@ def add_acl_to_thread(user, thread):
         'can_pin': category_acl.get('can_pin_threads', 0),
         'can_close': category_acl.get('can_close_threads', False),
         'can_move': category_acl.get('can_move_threads', False),
-        'can_review': category_acl.get('can_review_moderated_content', False),
+        'can_approve': category_acl.get('can_approve_content', False),
         'can_report': category_acl.get('can_report_content', False),
         'can_see_reports': category_acl.get('can_see_reports', False),
     })
@@ -352,10 +369,10 @@ def add_acl_to_post(user, post):
         'can_protect': category_acl.get('can_protect_posts', False),
         'can_report': category_acl.get('can_report_content', False),
         'can_see_reports': category_acl.get('can_see_reports', False),
-        'can_approve': category_acl.get('can_review_moderated_content', False),
+        'can_approve': category_acl.get('can_approve_content', False),
     })
 
-    if not post.is_moderated:
+    if not post.is_unapproved:
         post.acl['can_approve'] = False
 
     if not post.acl['can_see_hidden']:
@@ -391,8 +408,8 @@ def allow_see_thread(user, target):
     if user.is_anonymous() or user.pk != target.starter_id:
         if not category_acl.get('can_see_all_threads'):
             raise Http404()
-        if target.is_moderated:
-            if not category_acl.get('can_review_moderated_content'):
+        if target.is_unapproved:
+            if not category_acl.get('can_approve_content'):
                 raise Http404()
         if target.is_hidden and not category_acl.get('can_hide_threads'):
             raise Http404()
@@ -466,9 +483,9 @@ can_edit_thread = return_boolean(allow_edit_thread)
 
 
 def allow_see_post(user, target):
-    if target.is_moderated:
+    if target.is_unapproved:
         category_acl = user.acl['categories'].get(target.category_id, {})
-        if not category_acl.get('can_review_moderated_content'):
+        if not category_acl.get('can_approve_content'):
             if user.is_anonymous() or user.pk != target.poster_id:
                 raise Http404()
 can_see_post = return_boolean(allow_see_post)
@@ -686,21 +703,21 @@ def exclude_invisible_category_threads(queryset, user, category):
     if user.is_authenticated():
         condition_author = Q(starter_id=user.id)
 
-        can_mod = category.acl['can_review_moderated_content']
+        can_mod = category.acl['can_approve_content']
         can_hide = category.acl['can_hide_threads']
 
         if not can_mod and not can_hide:
-            condition = Q(is_moderated=False) & Q(is_hidden=False)
+            condition = Q(is_unapproved=False) & Q(is_hidden=False)
             queryset = queryset.filter(condition_author | condition)
         elif not can_mod:
-            condition = Q(is_moderated=False)
+            condition = Q(is_unapproved=False)
             queryset = queryset.filter(condition_author | condition)
         elif not can_hide:
             condition = Q(is_hidden=False)
             queryset = queryset.filter(condition_author | condition)
     else:
-        if not category.acl['can_review_moderated_content']:
-            queryset = queryset.filter(is_moderated=False)
+        if not category.acl['can_approve_content']:
+            queryset = queryset.filter(is_unapproved=False)
         if not category.acl['can_hide_threads']:
             queryset = queryset.filter(is_hidden=False)
 
@@ -723,7 +740,7 @@ def exclude_invisible_threads(user, categories, queryset):
 
         can_hide = category.acl['can_hide_threads']
         if category.acl['can_see_all_threads']:
-            can_mod = category.acl['can_review_moderated_content']
+            can_mod = category.acl['can_approve_content']
 
             if can_mod and can_hide:
                 show_all.append(category)
@@ -749,7 +766,7 @@ def exclude_invisible_threads(user, categories, queryset):
     if show_accepted_visible:
         if user.is_authenticated():
             condition = Q(
-                Q(starter=user) | Q(is_moderated=False),
+                Q(starter=user) | Q(is_unapproved=False),
                 category__in=show_accepted_visible,
                 is_hidden=False,
             )
@@ -757,7 +774,7 @@ def exclude_invisible_threads(user, categories, queryset):
             condition = Q(
                 category__in=show_accepted_visible,
                 is_hidden=False,
-                is_moderated=False,
+                is_unapproved=False,
             )
 
         if conditions:
@@ -767,7 +784,7 @@ def exclude_invisible_threads(user, categories, queryset):
 
     if show_accepted:
         condition = Q(
-            Q(starter=user) | Q(is_moderated=False),
+            Q(starter=user) | Q(is_unapproved=False),
             category__in=show_accepted,
         )
 
@@ -811,12 +828,12 @@ def exclude_invisible_threads(user, categories, queryset):
 
 
 def exclude_invisible_posts(queryset, user, category):
-    if not category.acl['can_review_moderated_content']:
+    if not category.acl['can_approve_content']:
         if user.is_authenticated():
             condition_author = Q(poster_id=user.id)
-            condition = Q(is_moderated=False)
+            condition = Q(is_unapproved=False)
             queryset = queryset.filter(condition_author | condition)
         else:
-            queryset = queryset.filter(is_moderated=False)
+            queryset = queryset.filter(is_unapproved=False)
 
     return queryset

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

@@ -24,7 +24,7 @@ class ThreadApiTestCase(AuthenticatedUserTestCase):
             'can_see_all_threads': 1,
             'can_see_own_threads': 0,
             'can_hide_threads': 0,
-            'can_review_moderated_content': 0,
+            'can_approve_content': 0,
         }
         final_acl.update(acl)
 

+ 10 - 10
misago/threads/tests/test_thread_model.py

@@ -73,13 +73,13 @@ class ThreadModelTests(TestCase):
         self.assertEqual(self.thread.last_poster_name, user.username)
         self.assertEqual(self.thread.last_poster_slug, user.slug)
         self.assertFalse(self.thread.has_reported_posts)
-        self.assertFalse(self.thread.has_moderated_posts)
+        self.assertFalse(self.thread.has_unapproved_posts)
         self.assertFalse(self.thread.has_hidden_posts)
         self.assertFalse(self.thread.has_events)
         self.assertEqual(self.thread.replies, 1)
 
-        # add moderated post
-        moderated_post = Post.objects.create(
+        # add unapproved post
+        unapproved_post = Post.objects.create(
             category=self.category,
             thread=self.thread,
             poster=user,
@@ -90,7 +90,7 @@ class ThreadModelTests(TestCase):
             checksum="nope",
             posted_on=datetime + timedelta(5),
             updated_on=datetime + timedelta(5),
-            is_moderated=True
+            is_unapproved=True
         )
 
         self.thread.synchronize()
@@ -100,7 +100,7 @@ class ThreadModelTests(TestCase):
         self.assertEqual(self.thread.last_poster_name, user.username)
         self.assertEqual(self.thread.last_poster_slug, user.slug)
         self.assertFalse(self.thread.has_reported_posts)
-        self.assertTrue(self.thread.has_moderated_posts)
+        self.assertTrue(self.thread.has_unapproved_posts)
         self.assertFalse(self.thread.has_hidden_posts)
         self.assertFalse(self.thread.has_events)
         self.assertEqual(self.thread.replies, 1)
@@ -127,7 +127,7 @@ class ThreadModelTests(TestCase):
         self.assertEqual(self.thread.last_poster_name, user.username)
         self.assertEqual(self.thread.last_poster_slug, user.slug)
         self.assertFalse(self.thread.has_reported_posts)
-        self.assertTrue(self.thread.has_moderated_posts)
+        self.assertTrue(self.thread.has_unapproved_posts)
         self.assertTrue(self.thread.has_hidden_posts)
         self.assertFalse(self.thread.has_events)
         self.assertEqual(self.thread.replies, 2)
@@ -144,14 +144,14 @@ class ThreadModelTests(TestCase):
         self.assertEqual(self.thread.last_poster_name, user.username)
         self.assertEqual(self.thread.last_poster_slug, user.slug)
         self.assertFalse(self.thread.has_reported_posts)
-        self.assertTrue(self.thread.has_moderated_posts)
+        self.assertTrue(self.thread.has_unapproved_posts)
         self.assertFalse(self.thread.has_hidden_posts)
         self.assertFalse(self.thread.has_events)
         self.assertEqual(self.thread.replies, 2)
 
         # unmoderate post
-        moderated_post.is_moderated = False
-        moderated_post.save()
+        unapproved_post.is_unapproved = False
+        unapproved_post.save()
 
         # last post not changed, but flags and count did
         self.thread.synchronize()
@@ -161,7 +161,7 @@ class ThreadModelTests(TestCase):
         self.assertEqual(self.thread.last_poster_name, user.username)
         self.assertEqual(self.thread.last_poster_slug, user.slug)
         self.assertFalse(self.thread.has_reported_posts)
-        self.assertFalse(self.thread.has_moderated_posts)
+        self.assertFalse(self.thread.has_unapproved_posts)
         self.assertFalse(self.thread.has_hidden_posts)
         self.assertFalse(self.thread.has_events)
         self.assertEqual(self.thread.replies, 3)

+ 566 - 0
misago/threads/tests/test_thread_patchapi.py

@@ -0,0 +1,566 @@
+import json
+
+from misago.acl.testutils import override_acl
+from misago.categories.models import Category
+
+from misago.threads.tests.test_thread_api import ThreadApiTestCase
+
+
+class ThreadPinGloballyApiTests(ThreadApiTestCase):
+    def test_pin_thread(self):
+        """api makes it possible to pin globally thread"""
+        self.override_acl({
+            'can_pin_threads': 2
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'weight', 'value': 2}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['weight'], 2)
+
+    def test_unpin_thread(self):
+        """api makes it possible to unpin thread"""
+        self.thread.weight = 2
+        self.thread.save()
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['weight'], 2)
+
+        self.override_acl({
+            'can_pin_threads': 2
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'weight', 'value': 0}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['weight'], 0)
+
+    def test_pin_thread_no_permission(self):
+        """api pin thread globally with no permission fails"""
+        self.override_acl({
+            'can_pin_threads': 1
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'weight', 'value': 2}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['detail'][0],
+            "You don't have permission to pin this thread globally.")
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['weight'], 0)
+
+    def test_unpin_thread_no_permission(self):
+        """api unpin thread with no permission fails"""
+        self.thread.weight = 2
+        self.thread.save()
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['weight'], 2)
+
+        self.override_acl({
+            'can_pin_threads': 1
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'weight', 'value': 1}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['detail'][0],
+            "You don't have permission to change this thread's weight.")
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['weight'], 2)
+
+
+class ThreadPinLocallyApiTests(ThreadApiTestCase):
+    def test_pin_thread(self):
+        """api makes it possible to pin locally thread"""
+        self.override_acl({
+            'can_pin_threads': 1
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'weight', 'value': 1}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['weight'], 1)
+
+    def test_unpin_thread(self):
+        """api makes it possible to unpin thread"""
+        self.thread.weight = 1
+        self.thread.save()
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['weight'], 1)
+
+        self.override_acl({
+            'can_pin_threads': 1
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'weight', 'value': 0}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['weight'], 0)
+
+    def test_pin_thread_no_permission(self):
+        """api pin thread locally with no permission fails"""
+        self.override_acl({
+            'can_pin_threads': 0
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'weight', 'value': 1}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['detail'][0],
+            "You don't have permission to change this thread's weight.")
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['weight'], 0)
+
+    def test_unpin_thread_no_permission(self):
+        """api unpin thread with no permission fails"""
+        self.thread.weight = 1
+        self.thread.save()
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['weight'], 1)
+
+        self.override_acl({
+            'can_pin_threads': 0
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'weight', 'value': 0}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['detail'][0],
+            "You don't have permission to change this thread's weight.")
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['weight'], 1)
+
+
+class ThreadMoveApiTests(ThreadApiTestCase):
+    def setUp(self):
+        super(ThreadMoveApiTests, self).setUp()
+
+        Category(
+            name='Category B',
+            slug='category-b',
+        ).insert_at(self.category, position='last-child', save=True)
+        self.category_b = Category.objects.get(slug='category-b')
+
+    def override_other_acl(self, acl):
+        final_acl = {
+            'can_see': 1,
+            'can_browse': 1,
+            'can_see_all_threads': 1,
+            'can_see_own_threads': 0,
+            'can_hide_threads': 0,
+            'can_approve_content': 0,
+        }
+        final_acl.update(acl)
+
+        categories_acl = self.user.acl['categories']
+        categories_acl[self.category_b.pk] = final_acl
+
+        visible_categories = [self.category.pk]
+        if final_acl['can_see']:
+            visible_categories.append(self.category_b.pk)
+
+        override_acl(self.user, {
+            'visible_categories': visible_categories,
+            'categories': categories_acl,
+        })
+
+    def test_move_thread(self):
+        """api moves thread to other category"""
+        self.override_acl({
+            'can_move_threads': True
+        })
+        self.override_other_acl({})
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'category', 'value': self.category_b.pk}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        self.override_other_acl({})
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['category']['id'], self.category_b.pk)
+
+    def test_move_thread_no_permission(self):
+        """api move thread to other category with no permission fails"""
+        self.override_acl({
+            'can_move_threads': False
+        })
+        self.override_other_acl({})
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'category', 'value': self.category_b.pk}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['detail'][0],
+            "You don't have permission to move this thread.")
+
+        self.override_other_acl({})
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['category']['id'], self.category.pk)
+
+    def test_move_thread_no_category_access(self):
+        """api move thread to category with no access fails"""
+        self.override_acl({
+            'can_move_threads': True
+        })
+        self.override_other_acl({
+            'can_see': False
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'category', 'value': self.category_b.pk}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['detail'][0], 'NOT FOUND')
+
+        self.override_other_acl({})
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['category']['id'], self.category.pk)
+
+    def test_move_thread_no_category_browse(self):
+        """api move thread to category with no browsing access fails"""
+        self.override_acl({
+            'can_move_threads': True
+        })
+        self.override_other_acl({
+            'can_browse': False
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'category', 'value': self.category_b.pk}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['detail'][0],
+            'You don\'t have permission to browse "Category B" contents.')
+
+        self.override_other_acl({})
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['category']['id'], self.category.pk)
+
+    def test_thread_flatten_categories(self):
+        """api flatten thread categories"""
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'flatten-categories', 'value': None}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['category'], self.category.pk)
+
+    def test_thread_top_flatten_categories(self):
+        """api flatten thread with top category"""
+        self.thread.category = self.category_b
+        self.thread.save()
+
+        self.override_other_acl({})
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {
+                'op': 'add',
+                'path': 'top-category',
+                'value': Category.objects.root_category().pk,
+            },
+            {'op': 'replace', 'path': 'flatten-categories', 'value': None},
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['top_category'], self.category.pk)
+        self.assertEqual(response_json['category'], self.category_b.pk)
+
+
+class ThreadCloseApiTests(ThreadApiTestCase):
+    def test_close_thread(self):
+        """api makes it possible to close thread"""
+        self.override_acl({
+            'can_close_threads': True
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'is-closed', 'value': True}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        thread_json = self.get_thread_json()
+        self.assertTrue(thread_json['is_closed'])
+
+    def test_open_thread(self):
+        """api makes it possible to open thread"""
+        self.thread.is_closed = True
+        self.thread.save()
+
+        thread_json = self.get_thread_json()
+        self.assertTrue(thread_json['is_closed'])
+
+        self.override_acl({
+            'can_close_threads': True
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'is-closed', 'value': False}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        thread_json = self.get_thread_json()
+        self.assertFalse(thread_json['is_closed'])
+
+    def test_close_thread_no_permission(self):
+        """api close thread with no permission fails"""
+        self.override_acl({
+            'can_close_threads': False
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'is-closed', 'value': True}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['detail'][0],
+            "You don't have permission to close this thread.")
+
+        thread_json = self.get_thread_json()
+        self.assertFalse(thread_json['is_closed'])
+
+    def test_open_thread_no_permission(self):
+        """api open thread with no permission fails"""
+        self.thread.is_closed = True
+        self.thread.save()
+
+        thread_json = self.get_thread_json()
+        self.assertTrue(thread_json['is_closed'])
+
+        self.override_acl({
+            'can_close_threads': False
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'is-closed', 'value': False}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['detail'][0],
+            "You don't have permission to open this thread.")
+
+        thread_json = self.get_thread_json()
+        self.assertTrue(thread_json['is_closed'])
+
+
+class ThreadHideApiTests(ThreadApiTestCase):
+    def test_hide_thread(self):
+        """api makes it possible to hide thread"""
+        self.override_acl({
+            'can_hide_threads': 1
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'is-hidden', 'value': True}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        self.override_acl({
+            'can_hide_threads': 1
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertTrue(thread_json['is_hidden'])
+
+    def test_show_thread(self):
+        """api makes it possible to unhide thread"""
+        self.thread.is_hidden = True
+        self.thread.save()
+
+        self.override_acl({
+            'can_hide_threads': 1
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertTrue(thread_json['is_hidden'])
+
+        self.override_acl({
+            'can_hide_threads': 1
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'is-hidden', 'value': False}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        self.override_acl({
+            'can_hide_threads': 1
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertFalse(thread_json['is_hidden'])
+
+    def test_hide_thread_no_permission(self):
+        """api hide thread with no permission fails"""
+        self.override_acl({
+            'can_hide_threads': 0
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'is-hidden', 'value': True}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['detail'][0],
+            "You don't have permission to hide this thread.")
+
+        thread_json = self.get_thread_json()
+        self.assertFalse(thread_json['is_hidden'])
+
+    def test_show_thread_no_permission(self):
+        """api unhide thread with no permission fails"""
+        self.thread.is_hidden = True
+        self.thread.save()
+
+        self.override_acl({
+            'can_hide_threads': 1
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertTrue(thread_json['is_hidden'])
+
+        self.override_acl({
+            'can_hide_threads': 0
+        })
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'is-hidden', 'value': False}
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 404)
+
+
+class ThreadSubscribeApiTests(ThreadApiTestCase):
+    def test_subscribe_thread(self):
+        """api makes it possible to subscribe thread"""
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'subscription', 'value': 'notify'}
+        ]),
+        content_type="application/json")
+
+        self.assertEqual(response.status_code, 200)
+
+        thread_json = self.get_thread_json()
+        self.assertFalse(thread_json['subscription'])
+
+        subscription = self.user.subscription_set.get(thread=self.thread)
+        self.assertFalse(subscription.send_email)
+
+    def test_subscribe_thread_with_email(self):
+        """api makes it possible to subscribe thread with emails"""
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'subscription', 'value': 'email'}
+        ]),
+        content_type="application/json")
+
+        self.assertEqual(response.status_code, 200)
+
+        thread_json = self.get_thread_json()
+        self.assertTrue(thread_json['subscription'])
+
+        subscription = self.user.subscription_set.get(thread=self.thread)
+        self.assertTrue(subscription.send_email)
+
+    def test_unsubscribe_thread(self):
+        """api makes it possible to unsubscribe thread"""
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'subscription', 'value': 'remove'}
+        ]),
+        content_type="application/json")
+
+        self.assertEqual(response.status_code, 200)
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['subscription'])
+
+        self.assertEqual(self.user.subscription_set.count(), 0)
+
+    def test_subscribe_as_guest(self):
+        """api makes it impossible to subscribe thread"""
+        self.logout_user()
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'subscription', 'value': 'email'}
+        ]),
+        content_type="application/json")
+
+        self.assertEqual(response.status_code, 403)
+
+    def test_subscribe_nonexistant_thread(self):
+        """api makes it impossible to subscribe nonexistant thread"""
+        bad_api_link = self.api_link.replace(
+            unicode(self.thread.pk), unicode(self.thread.pk + 9))
+
+        response = self.client.patch(bad_api_link, json.dumps([
+            {'op': 'replace', 'path': 'subscription', 'value': 'email'}
+        ]),
+        content_type="application/json")
+
+        self.assertEqual(response.status_code, 404)

+ 7 - 7
misago/threads/tests/test_threads_moderation.py

@@ -102,16 +102,16 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         self.assertEqual(self.thread.weight, 0)
 
     def test_approve_thread(self):
-        """approve_thread approves moderated thread"""
-        thread = testutils.post_thread(self.category, is_moderated=True)
+        """approve_thread approves unapproved thread"""
+        thread = testutils.post_thread(self.category, is_unapproved=True)
 
-        self.assertTrue(thread.is_moderated)
-        self.assertTrue(thread.first_post.is_moderated)
+        self.assertTrue(thread.is_unapproved)
+        self.assertTrue(thread.first_post.is_unapproved)
         self.assertTrue(moderation.approve_thread(self.user, thread))
 
         self.reload_thread()
-        self.assertFalse(thread.is_moderated)
-        self.assertFalse(thread.first_post.is_moderated)
+        self.assertFalse(thread.is_unapproved)
+        self.assertFalse(thread.first_post.is_unapproved)
         self.assertTrue(thread.has_events)
         event = thread.event_set.last()
 
@@ -119,7 +119,7 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         self.assertEqual(event.icon, "check")
 
     def test_move_thread(self):
-        """moves_thread moves moderated thread to other category"""
+        """moves_thread moves unapproved thread to other category"""
         root_category = Category.objects.root_category()
         Category(
             name='New Category',

+ 77 - 14
misago/threads/tests/test_threadslists.py

@@ -19,7 +19,7 @@ LISTS_URLS = (
     'my/',
     'new/',
     'unread/',
-    'subscribed/'
+    'subscribed/',
 )
 
 
@@ -108,8 +108,13 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
     def access_all_categories(self, extra=None):
         self.clear_state()
 
-        categories_acl = {'categories': {}, 'visible_categories': []}
-        for category in Category.objects.all_categories():
+        categories_acl = {
+            'categories': {},
+            'visible_categories': [],
+            'can_approve_content': [],
+        }
+
+        for category in Category.objects.all_categories(include_root=True):
             categories_acl['visible_categories'].append(category.pk)
             categories_acl['categories'][category.pk] = {
                 'can_see': 1,
@@ -117,11 +122,13 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
                 'can_see_all_threads': 1,
                 'can_see_own_threads': 0,
                 'can_hide_threads': 0,
-                'can_review_moderated_content': 0,
+                'can_approve_content': 0,
             }
 
             if extra:
                 categories_acl['categories'][category.pk].update(extra)
+                if extra.get('can_approve_content'):
+                    categories_acl['can_approve_content'].append(category.pk)
 
         override_acl(self.user, categories_acl)
         return categories_acl
@@ -572,12 +579,12 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         response_json = json_loads(response.content)
         self.assertEqual(len(response_json['results']), 0)
 
-    def test_list_user_see_own_moderated_thread(self):
-        """list renders moderated thread that belongs to viewer"""
+    def test_list_user_see_own_unapproved_thread(self):
+        """list renders unapproved thread that belongs to viewer"""
         test_thread = testutils.post_thread(
             category=self.category_a,
             poster=self.user,
-            is_moderated=True,
+            is_unapproved=True,
         )
 
         response = self.client.get('/')
@@ -592,11 +599,11 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         response_json = json_loads(response.content)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
-    def test_list_user_cant_see_moderated_thread(self):
-        """list hides moderated thread that belongs to other user"""
+    def test_list_user_cant_see_unapproved_thread(self):
+        """list hides unapproved thread that belongs to other user"""
         test_thread = testutils.post_thread(
             category=self.category_a,
-            is_moderated=True,
+            is_unapproved=True,
         )
 
         response = self.client.get('/')
@@ -705,17 +712,17 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         response_json = json_loads(response.content)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
-    def test_list_user_can_see_moderated_thread(self):
+    def test_list_user_can_see_unapproved_thread(self):
         """
         list shows hidden thread that belongs to other user due to permission
         """
         test_thread = testutils.post_thread(
             category=self.category_a,
-            is_moderated=True,
+            is_unapproved=True,
         )
 
         self.access_all_categories({
-            'can_review_moderated_content': 1
+            'can_approve_content': 1
         })
 
         response = self.client.get('/')
@@ -724,7 +731,7 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
 
         # test api
         self.access_all_categories({
-            'can_review_moderated_content': 1
+            'can_approve_content': 1
         })
 
         response = self.client.get(self.api_link)
@@ -1465,3 +1472,59 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
         response_json = json_loads(response.content)
         self.assertEqual(len(response_json['results']), 0)
         self.assertNotIn(test_thread.get_absolute_url(), response.content)
+
+
+class UnapprovedListTests(ThreadsListTestCase):
+    def test_list_errors_without_permission(self):
+        """list errors if user has no permission to access it"""
+        self.access_all_categories()
+        response = self.client.get('/unapproved/')
+        self.assertEqual(response.status_code, 403)
+
+        self.access_all_categories()
+        response = self.client.get(
+            self.category_a.get_absolute_url() + 'unapproved/')
+        self.assertEqual(response.status_code, 403)
+
+        # test api
+        self.access_all_categories()
+        response = self.client.get('%s?list=unapproved' % self.api_link)
+        self.assertEqual(response.status_code, 403)
+
+    def test_list_shows_right_threads(self):
+        """list shows threads with unapproved posts"""
+        visible_thread = testutils.post_thread(
+            category=self.category_b,
+            is_unapproved=True,
+        )
+
+        hidden_thread = testutils.post_thread(
+            category=self.category_b,
+            is_unapproved=False,
+        )
+
+        self.access_all_categories({
+            'can_approve_content': True
+        })
+        response = self.client.get('/unapproved/')
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(visible_thread.get_absolute_url(), response.content)
+        self.assertNotIn(hidden_thread.get_absolute_url(), response.content)
+
+        self.access_all_categories({
+            'can_approve_content': True
+        })
+        response = self.client.get(
+            self.category_a.get_absolute_url() + 'unapproved/')
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(visible_thread.get_absolute_url(), response.content)
+        self.assertNotIn(hidden_thread.get_absolute_url(), response.content)
+
+        # test api
+        self.access_all_categories({
+            'can_approve_content': True
+        })
+        response = self.client.get('%s?list=unapproved' % self.api_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(visible_thread.get_absolute_url(), response.content)
+        self.assertNotIn(hidden_thread.get_absolute_url(), response.content)

+ 5 - 5
misago/threads/testutils.py

@@ -8,7 +8,7 @@ from misago.threads.models import Thread, Post
 
 
 def post_thread(category, title='Test thread', poster='Tester',
-                is_global=False, is_pinned=False, is_moderated=False,
+                is_global=False, is_pinned=False, is_unapproved=False,
                 is_hidden=False, is_closed=False, started_on=None):
     started_on = started_on or timezone.now()
 
@@ -18,7 +18,7 @@ def post_thread(category, title='Test thread', poster='Tester',
         'slug': slugify(title),
         'started_on': started_on,
         'last_post_on': started_on,
-        'is_moderated': is_moderated,
+        'is_unapproved': is_unapproved,
         'is_hidden': is_hidden,
         'is_closed': is_closed,
     }
@@ -50,14 +50,14 @@ def post_thread(category, title='Test thread', poster='Tester',
         poster=poster,
         posted_on=thread.last_post_on,
         is_hidden=is_hidden,
-        is_moderated=is_moderated,
+        is_unapproved=is_unapproved,
     )
 
     return thread
 
 
 def reply_thread(thread, poster="Tester", message='I am test message',
-                 is_moderated=False, is_hidden=False, has_reports=False,
+                 is_unapproved=False, is_hidden=False, has_reports=False,
                  has_open_reports=False, posted_on=None, poster_ip='127.0.0.1'):
     posted_on = posted_on or thread.last_post_on + timedelta(minutes=5)
 
@@ -70,7 +70,7 @@ def reply_thread(thread, poster="Tester", message='I am test message',
         'poster_ip': poster_ip,
         'posted_on': posted_on,
         'updated_on': posted_on,
-        'is_moderated': is_moderated,
+        'is_unapproved': is_unapproved,
         'is_hidden': is_hidden,
         'has_reports': has_reports,
         'has_open_reports': has_open_reports,

+ 5 - 0
misago/threads/urls/__init__.py

@@ -11,6 +11,7 @@ PATTERNS_KWARGS = (
     {'list_type': 'new'},
     {'list_type': 'unread'},
     {'list_type': 'subscribed'},
+    {'list_type': 'unapproved'},
 )
 
 
@@ -40,6 +41,7 @@ if settings.MISAGO_CATEGORIES_ON_INDEX:
         r'^threads/new/$',
         r'^threads/unread/$',
         r'^threads/subscribed/$',
+        r'^threads/unapproved/$',
     ))
 else:
     urlpatterns = threads_list_patterns('threads', ThreadsList, (
@@ -48,6 +50,7 @@ else:
         r'^new/$',
         r'^unread/$',
         r'^subscribed/$',
+        r'^unapproved/$',
     ))
 
 
@@ -57,6 +60,7 @@ urlpatterns += threads_list_patterns('category', CategoryThreadsList, (
     r'^category/(?P<slug>[-a-zA-Z0-9]+)-(?P<pk>\d+)/new/$',
     r'^category/(?P<slug>[-a-zA-Z0-9]+)-(?P<pk>\d+)/unread/$',
     r'^category/(?P<slug>[-a-zA-Z0-9]+)-(?P<pk>\d+)/subscribed/$',
+    r'^category/(?P<slug>[-a-zA-Z0-9]+)-(?P<pk>\d+)/unapproved/$',
 ))
 
 
@@ -66,4 +70,5 @@ urlpatterns += threads_list_patterns('private-threads', CategoryThreadsList, (
     r'^private-threads/new/$',
     r'^private-threads/unread/$',
     r'^private-threads/subscribed/$',
+    r'^private-threads/unapproved/$',
 ))

+ 1 - 1
misago/threads/views/threadslist.py

@@ -166,7 +166,7 @@ class CategoryThreadsList(ThreadsList, ThreadsListMixin):
     def get_category(self, request, categories, **kwargs):
         for category in categories:
             if category.pk == int(kwargs['pk']):
-                if category.special_role:
+                if not category.level:
                     raise Http404()
 
                 allow_see_category(request.user, category)