Browse Source

post private thread

Rafał Pitoń 8 years ago
parent
commit
d07115c140

+ 1 - 1
misago/templates/misago/emails/privatethread/added.html

@@ -12,7 +12,7 @@
 <br>
 <br>
 <br>
 <br>
 {% blocktrans trimmed %}
 {% blocktrans trimmed %}
-To go to this thread click the link below:
+To read this thread click the link below:
 {% endblocktrans %}
 {% endblocktrans %}
 <br>
 <br>
 <br>
 <br>

+ 1 - 1
misago/templates/misago/emails/privatethread/added.txt

@@ -8,7 +8,7 @@
 {% endblocktrans %}
 {% endblocktrans %}
 
 
 {% blocktrans trimmed %}
 {% blocktrans trimmed %}
-To go to this thread click the link below:
+To read this thread click the link below:
 {% endblocktrans %}
 {% endblocktrans %}
 {{ SITE_ADDRESS }}{{ thread.get_absolute_url }}
 {{ SITE_ADDRESS }}{{ thread.get_absolute_url }}
 {% endblock content %}
 {% endblock content %}

+ 1 - 1
misago/templates/misago/emails/thread/reply.html

@@ -11,7 +11,7 @@
 {% endblocktrans %}
 {% endblocktrans %}
 <br>
 <br>
 <br>
 <br>
-{% trans "To go to this reply, click the below link:" %}
+{% trans "To read this reply, click the below link:" %}
 <br>
 <br>
 <br>
 <br>
 <a href="{{ SITE_ADDRESS }}{{ post.get_absolute_url }}">{% trans "Go to reply" %}</a>
 <a href="{{ SITE_ADDRESS }}{{ post.get_absolute_url }}">{% trans "Go to reply" %}</a>

+ 1 - 1
misago/templates/misago/emails/thread/reply.txt

@@ -7,6 +7,6 @@
 {{ user }}, you are receiving this message because {{ poster }} has replied to the thread "{{ thread }}" that you are subscribed to.
 {{ user }}, you are receiving this message because {{ poster }} has replied to the thread "{{ thread }}" that you are subscribed to.
 {% endblocktrans %}
 {% endblocktrans %}
 
 
-{% trans "To go to this reply, click the below link:" %}
+{% trans "To read this reply, click the below link:" %}
 {{ SITE_ADDRESS }}{{ post.get_absolute_url }}
 {{ SITE_ADDRESS }}{{ post.get_absolute_url }}
 {% endblock content %}
 {% endblock content %}

+ 16 - 10
misago/threads/api/postingendpoint/participants.py

@@ -8,7 +8,7 @@ from rest_framework import serializers
 from misago.categories.models import PRIVATE_THREADS_ROOT_NAME
 from misago.categories.models import PRIVATE_THREADS_ROOT_NAME
 
 
 from . import PostingEndpoint, PostingMiddleware
 from . import PostingEndpoint, PostingMiddleware
-from ...participants import add_owner, add_participant
+from ...participants import set_owner, add_participants
 from ...permissions import allow_message_user
 from ...permissions import allow_message_user
 
 
 
 
@@ -24,19 +24,19 @@ class ParticipantsMiddleware(PostingMiddleware):
         })
         })
 
 
     def save(self, serializer):
     def save(self, serializer):
-        add_owner(self.thread, self.user)
-        for user in serializer.users_cache:
-            add_participant(self.request, self.thread, user)
+        set_owner(self.thread, self.user)
+        add_participants(self.request, self.thread, serializer.users_cache)
 
 
 
 
 class ParticipantsSerializer(serializers.Serializer):
 class ParticipantsSerializer(serializers.Serializer):
     to = serializers.ListField(
     to = serializers.ListField(
-       child=serializers.CharField()
+       child=serializers.CharField(),
+       required=True
     )
     )
 
 
     def validate_to(self, usernames):
     def validate_to(self, usernames):
         clean_usernames = self.clean_usernames(usernames)
         clean_usernames = self.clean_usernames(usernames)
-        self.users_cache = self.get_users(usernames)
+        self.users_cache = self.get_users(clean_usernames)
 
 
     def clean_usernames(self, usernames):
     def clean_usernames(self, usernames):
         clean_usernames = []
         clean_usernames = []
@@ -50,13 +50,19 @@ class ParticipantsSerializer(serializers.Serializer):
             if clean_name and clean_name not in clean_usernames:
             if clean_name and clean_name not in clean_usernames:
                 clean_usernames.append(clean_name)
                 clean_usernames.append(clean_name)
 
 
+        if not clean_usernames:
+            raise serializers.ValidationError(_("You have to enter user names."))
+
         max_participants = self.context['user'].acl['max_private_thread_participants']
         max_participants = self.context['user'].acl['max_private_thread_participants']
         if max_participants and len(clean_usernames) > max_participants:
         if max_participants and len(clean_usernames) > max_participants:
             message = ungettext(
             message = ungettext(
-                "You can't start private thread with more than %(users)s participant.",
-                "You can't start private thread with more than %(users)s participants.",
+                "You can't add more than %(users)s user to private thread (you've added %(added)s).",
+                "You can't add more than %(users)s users to private thread (you've added %(added)s).",
                 max_participants)
                 max_participants)
-            raise forms.ValidationError(message % {'users': max_participants})
+            raise serializers.ValidationError(message % {
+                'users': max_participants,
+                'added': len(clean_usernames)
+            })
 
 
         return list(set(clean_usernames))
         return list(set(clean_usernames))
 
 
@@ -66,7 +72,7 @@ class ParticipantsSerializer(serializers.Serializer):
             try:
             try:
                 allow_message_user(self.context['user'], user)
                 allow_message_user(self.context['user'], user)
             except PermissionDenied as e:
             except PermissionDenied as e:
-                raise serializer.ValidationError(six.text_type(e))
+                raise serializers.ValidationError(six.text_type(e))
             users.append(user)
             users.append(user)
 
 
         if len(usernames) != len(users):
         if len(usernames) != len(users):

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

@@ -11,6 +11,7 @@ from misago.core.shortcuts import get_int_or_404
 
 
 from ..models import Post, Thread
 from ..models import Post, Thread
 from ..moderation import threads as moderation
 from ..moderation import threads as moderation
+from ..permissions import allow_use_private_threads
 from ..viewmodels import ForumThread
 from ..viewmodels import ForumThread
 from .postingendpoint import PostingEndpoint
 from .postingendpoint import PostingEndpoint
 from .threadendpoints.editor import thread_start_editor
 from .threadendpoints.editor import thread_start_editor
@@ -114,6 +115,10 @@ class PrivateThreadViewSet(ViewSet):
 
 
     @transaction.atomic
     @transaction.atomic
     def create(self, request):
     def create(self, request):
+        allow_use_private_threads(request.user)
+        if not request.user.acl['can_start_private_threads']:
+            raise PermissionDenied(_("You can't start private threads."))
+
         # Initialize empty instances for new thread
         # Initialize empty instances for new thread
         thread = Thread()
         thread = Thread()
         post = Post(thread=thread)
         post = Post(thread=thread)

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

@@ -84,7 +84,7 @@ class Thread(models.Model):
 
 
     participants = models.ManyToManyField(
     participants = models.ManyToManyField(
         settings.AUTH_USER_MODEL,
         settings.AUTH_USER_MODEL,
-        related_name='private_thread_set',
+        related_name='privatethread_set',
         through='ThreadParticipant',
         through='ThreadParticipant',
         through_fields=('thread', 'user')
         through_fields=('thread', 'user')
     )
     )

+ 16 - 10
misago/threads/models/threadparticipant.py

@@ -4,30 +4,36 @@ from misago.conf import settings
 
 
 
 
 class ThreadParticipantManager(models.Manager):
 class ThreadParticipantManager(models.Manager):
-    def remove_participant(self, thread, user):
-        ThreadParticipant.objects.filter(thread=thread, user=user).delete()
-
     def set_owner(self, thread, user):
     def set_owner(self, thread, user):
-        # remove existing owner
         ThreadParticipant.objects.filter(
         ThreadParticipant.objects.filter(
             thread=thread,
             thread=thread,
             is_owner=True
             is_owner=True
         ).update(is_owner=False)
         ).update(is_owner=False)
 
 
-        # add (or re-add) user as thread owner
         self.remove_participant(thread, user)
         self.remove_participant(thread, user)
+
         ThreadParticipant.objects.create(
         ThreadParticipant.objects.create(
             thread=thread,
             thread=thread,
             user=user,
             user=user,
             is_owner=True
             is_owner=True
         )
         )
 
 
-    def add_participant(self, thread, user, is_owner=False):
-        ThreadParticipant.objects.create(
+    def add_participants(self, thread, users):
+        bulk = []
+        for user in users:
+            bulk.append(ThreadParticipant(
+                thread=thread,
+                user=user,
+                is_owner=False
+            ))
+
+        ThreadParticipant.objects.bulk_create(bulk)
+
+    def remove_participant(self, thread, user):
+        ThreadParticipant.objects.filter(
             thread=thread,
             thread=thread,
-            user=user,
-            is_owner=is_owner
-        )
+            user=user
+        ).delete()
 
 
 
 
 class ThreadParticipant(models.Model):
 class ThreadParticipant(models.Model):

+ 43 - 24
misago/threads/participants.py

@@ -1,16 +1,17 @@
+from django.contrib.auth import get_user_model
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
 
 
-from misago.core.mail import mail_user
+from misago.core.mail import build_mail, send_messages
 
 
 from .models import ThreadParticipant
 from .models import ThreadParticipant
 from .signals import remove_thread_participant
 from .signals import remove_thread_participant
 
 
 
 
-def thread_has_participants(thread):
+def has_participants(thread):
     return thread.threadparticipant_set.exists()
     return thread.threadparticipant_set.exists()
 
 
 
 
-def make_thread_participants_aware(user, thread):
+def make_participants_aware(user, thread):
     thread.participants_list = []
     thread.participants_list = []
     thread.participant = None
     thread.participant = None
 
 
@@ -24,39 +25,57 @@ def make_thread_participants_aware(user, thread):
     return thread.participants_list
     return thread.participants_list
 
 
 
 
-def set_thread_owner(thread, user):
+def set_owner(thread, user):
+    """
+    Remove user's ownership over thread
+    """
     ThreadParticipant.objects.set_owner(thread, user)
     ThreadParticipant.objects.set_owner(thread, user)
 
 
 
 
-def set_user_unread_private_threads_sync(user):
-    user.sync_unread_private_threads = True
-    user.save(update_fields=['sync_unread_private_threads'])
+def set_users_unread_private_threads_sync(users):
+    User = get_user_model()
+    User.objects.filter(id__in=[u.pk for u in users]).update(
+        sync_unread_private_threads=True
+    )
 
 
 
 
 def add_participant(request, thread, user):
 def add_participant(request, thread, user):
     """
     """
-    Add participant to thread, set "recound private threads" flag on user,
-    notify user about being added to thread and mail him about it
+    Shortcut for adding single participant to thread
     """
     """
-    ThreadParticipant.objects.add_participant(thread, user)
+    add_participants(request, thread, [user])
 
 
-    set_user_unread_private_threads_sync(user)
 
 
-    mail_subject = _("%(thread)s - %(user)s added you to private thread")
-    subject_formats = {'thread': thread.title, 'user': request.user.username}
+def add_participants(request, thread, users):
+    """
+    Add multiple participants to thread, set "recound private threads" flag on them
+    notify them about being added to thread
+    """
+    ThreadParticipant.objects.add_participants(thread, users)
+    set_users_unread_private_threads_sync(users)
 
 
-    mail_user(request, user, mail_subject % subject_formats,
-              'misago/emails/privatethread/added',
-              {'thread': thread})
+    emails = []
+    for user in users:
+        emails.append(build_noticiation_email(request, thread, user))
+    send_messages(emails)
 
 
 
 
-def add_owner(thread, user):
-    """
-    Add owner to thread, set "recound private threads" flag on user,
-    notify user about being added to thread
-    """
-    ThreadParticipant.objects.add_participant(thread, user, True)
-    set_user_unread_private_threads_sync(user)
+def build_noticiation_email(request, thread, user):
+    subject = _('%(user)s has invited you to participate in private thread "%(thread)s"')
+    subject_formats = {
+        'thread': thread.title,
+        'user': request.user.username
+    }
+
+    return build_mail(
+        request,
+        user,
+        subject % subject_formats,
+        'misago/emails/privatethread/added',
+        {
+            'thread': thread
+        }
+    )
 
 
 
 
 def remove_participant(thread, user):
 def remove_participant(thread, user):
@@ -64,6 +83,6 @@ def remove_participant(thread, user):
     Remove thread participant, set "recound private threads" flag on user
     Remove thread participant, set "recound private threads" flag on user
     """
     """
     thread.threadparticipant_set.filter(user=user).delete()
     thread.threadparticipant_set.filter(user=user).delete()
-    set_user_unread_private_threads_sync(user)
+    set_users_unread_private_threads_sync([user])
 
 
     remove_thread_participant.send(thread, user=user)
     remove_thread_participant.send(thread, user=user)

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

@@ -193,7 +193,7 @@ def allow_message_user(user, target):
             _("%(user)s can't participate in private threads.") % message_format)
             _("%(user)s can't participate in private threads.") % message_format)
 
 
     if user.acl['can_add_everyone_to_private_threads']:
     if user.acl['can_add_everyone_to_private_threads']:
-        return None
+        return
 
 
     if user.acl['can_be_blocked'] and target.is_blocking(user):
     if user.acl['can_be_blocked'] and target.is_blocking(user):
         raise PermissionDenied(_("%(user)s is blocking you.") % message_format)
         raise PermissionDenied(_("%(user)s is blocking you.") % message_format)
@@ -203,7 +203,7 @@ def allow_message_user(user, target):
             _("%(user)s is not allowing invitations to private threads.") % message_format)
             _("%(user)s is not allowing invitations to private threads.") % message_format)
 
 
     if target.can_be_messaged_by_followed and not target.is_following(user):
     if target.can_be_messaged_by_followed and not target.is_following(user):
-        message = _("%(user)s is allowing invitations to private threads only from followed users.")
+        message = _("%(user)s limits invitations to private threads to followed users.")
         raise PermissionDenied(message % message_format)
         raise PermissionDenied(message % message_format)
 can_message_user = return_boolean(allow_message_user)
 can_message_user = return_boolean(allow_message_user)
 
 

+ 1 - 1
misago/threads/signals.py

@@ -138,7 +138,7 @@ def update_usernames(sender, **kwargs):
 
 
 @receiver(pre_delete, sender=get_user_model())
 @receiver(pre_delete, sender=get_user_model())
 def remove_unparticipated_private_threads(sender, **kwargs):
 def remove_unparticipated_private_threads(sender, **kwargs):
-    threads_qs = kwargs['instance'].private_thread_set.all()
+    threads_qs = kwargs['instance'].privatethread_set.all()
     for thread in batch_update(threads_qs, 50):
     for thread in batch_update(threads_qs, 50):
         if thread.participants.count() == 1:
         if thread.participants.count() == 1:
             with transaction.atomic():
             with transaction.atomic():

+ 3 - 2
misago/threads/tests/test_emailnotification_middleware.py

@@ -6,7 +6,7 @@ from datetime import timedelta
 
 
 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.core.urlresolvers import reverse
+from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.encoding import smart_str
 from django.utils.encoding import smart_str
 
 
@@ -32,7 +32,8 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             'thread_pk': self.thread.pk
             'thread_pk': self.thread.pk
         })
         })
 
 
-        self.other_user = get_user_model().objects.create_user('Bob', 'bob@boberson.com', 'pass123')
+        self.other_user = get_user_model().objects.create_user(
+            'Bob', 'bob@boberson.com', 'pass123')
 
 
     def override_acl(self):
     def override_acl(self):
         new_acl = deepcopy(self.user.acl)
         new_acl = deepcopy(self.user.acl)

+ 48 - 75
misago/threads/tests/test_participants.py

@@ -6,12 +6,11 @@ from misago.categories.models import Category
 
 
 from ..models import Post, Thread, ThreadParticipant
 from ..models import Post, Thread, ThreadParticipant
 from ..participants import (
 from ..participants import (
-    add_owner,
-    make_thread_participants_aware,
+    has_participants,
+    make_participants_aware,
     remove_participant,
     remove_participant,
-    set_thread_owner,
-    set_user_unread_private_threads_sync,
-    thread_has_participants
+    set_owner,
+    set_users_unread_private_threads_sync,
 )
 )
 
 
 
 
@@ -49,40 +48,35 @@ class ParticipantsTests(TestCase):
         self.thread.last_post = post
         self.thread.last_post = post
         self.thread.save()
         self.thread.save()
 
 
-    def test_thread_has_participants(self):
-        """thread_has_participants returns true if thread has participants"""
+    def test_has_participants(self):
+        """has_participants returns true if thread has participants"""
         User = get_user_model()
         User = get_user_model()
-        user = User.objects.create_user(
-            "Bob", "bob@boberson.com", "Pass.123")
-        other_user = User.objects.create_user(
-            "Bob2", "bob2@boberson.com", "Pass.123")
+        users = [
+            User.objects.create_user("Bob", "bob@boberson.com", "Pass.123"),
+            User.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123"),
+        ]
 
 
-        self.assertFalse(thread_has_participants(self.thread))
+        self.assertFalse(has_participants(self.thread))
 
 
-        ThreadParticipant.objects.add_participant(self.thread, user)
-        self.assertTrue(thread_has_participants(self.thread))
-
-        ThreadParticipant.objects.add_participant(self.thread, other_user)
-        self.assertTrue(thread_has_participants(self.thread))
+        ThreadParticipant.objects.add_participants(self.thread, users)
+        self.assertTrue(has_participants(self.thread))
 
 
         self.thread.threadparticipant_set.all().delete()
         self.thread.threadparticipant_set.all().delete()
-        self.assertFalse(thread_has_participants(self.thread))
+        self.assertFalse(has_participants(self.thread))
 
 
-    def test_make_thread_participants_aware(self):
+    def test_make_participants_aware(self):
         """
         """
-        make_thread_participants_aware sets participants_list and participant
-        adnotations on thread model
+        make_participants_aware sets participants_list and participant
+        annotations on thread model
         """
         """
         User = get_user_model()
         User = get_user_model()
-        user = User.objects.create_user(
-            "Bob", "bob@boberson.com", "Pass.123")
-        other_user = User.objects.create_user(
-            "Bob2", "bob2@boberson.com", "Pass.123")
+        user = User.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
+        other_user = User.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123")
 
 
         self.assertFalse(hasattr(self.thread, 'participants_list'))
         self.assertFalse(hasattr(self.thread, 'participants_list'))
         self.assertFalse(hasattr(self.thread, 'participant'))
         self.assertFalse(hasattr(self.thread, 'participant'))
 
 
-        make_thread_participants_aware(user, self.thread)
+        make_participants_aware(user, self.thread)
 
 
         self.assertTrue(hasattr(self.thread, 'participants_list'))
         self.assertTrue(hasattr(self.thread, 'participants_list'))
         self.assertTrue(hasattr(self.thread, 'participant'))
         self.assertTrue(hasattr(self.thread, 'participant'))
@@ -90,10 +84,10 @@ class ParticipantsTests(TestCase):
         self.assertEqual(self.thread.participants_list, [])
         self.assertEqual(self.thread.participants_list, [])
         self.assertIsNone(self.thread.participant)
         self.assertIsNone(self.thread.participant)
 
 
-        ThreadParticipant.objects.add_participant(self.thread, user, True)
-        ThreadParticipant.objects.add_participant(self.thread, other_user)
+        ThreadParticipant.objects.set_owner(self.thread, user)
+        ThreadParticipant.objects.add_participants(self.thread, [other_user])
 
 
-        make_thread_participants_aware(user, self.thread)
+        make_participants_aware(user, self.thread)
 
 
         self.assertEqual(self.thread.participant.user, user)
         self.assertEqual(self.thread.participant.user, user)
         for participant in self.thread.participants_list:
         for participant in self.thread.participants_list:
@@ -102,60 +96,39 @@ class ParticipantsTests(TestCase):
         else:
         else:
             self.fail("thread.participants_list didn't contain user")
             self.fail("thread.participants_list didn't contain user")
 
 
-    def test_set_thread_owner(self):
-        """set_thread_owner sets user as thread owner"""
-        User = get_user_model()
-        user = User.objects.create_user(
-            "Bob", "bob@boberson.com", "Pass.123")
-
-        set_thread_owner(self.thread, user)
-
-        owner = self.thread.threadparticipant_set.get(is_owner=True)
-        self.assertEqual(user, owner.user)
-
-    def test_set_user_unread_private_threads_sync(self):
-        """
-        set_user_unread_private_threads_sync sets sync_unread_private_threads
-        flag on user model to true
-        """
+    def test_remove_participant(self):
+        """remove_participant removes user from thread"""
         User = get_user_model()
         User = get_user_model()
-        user = User.objects.create_user(
-            "Bob", "bob@boberson.com", "Pass.123")
-
-        self.assertFalse(user.sync_unread_private_threads)
+        user = User.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
 
 
-        set_user_unread_private_threads_sync(user)
-        self.assertTrue(user.sync_unread_private_threads)
+        set_owner(self.thread, user)
+        remove_participant(self.thread, user)
 
 
-        db_user = User.objects.get(pk=user.pk)
-        self.assertTrue(db_user.sync_unread_private_threads)
+        self.assertEqual(self.thread.participants.count(), 0)
+        with self.assertRaises(ThreadParticipant.DoesNotExist):
+            self.thread.threadparticipant_set.get(user=user)
 
 
-    def test_add_owner(self):
-        """add_owner adds user as thread owner"""
+    def test_set_owner(self):
+        """set_owner sets user as thread owner"""
         User = get_user_model()
         User = get_user_model()
-        user = User.objects.create_user(
-            "Bob", "bob@boberson.com", "Pass.123")
+        user = User.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
 
 
-        add_owner(self.thread, user)
-        self.assertTrue(user.sync_unread_private_threads)
+        set_owner(self.thread, user)
 
 
         owner = self.thread.threadparticipant_set.get(is_owner=True)
         owner = self.thread.threadparticipant_set.get(is_owner=True)
         self.assertEqual(user, owner.user)
         self.assertEqual(user, owner.user)
 
 
-    def test_remove_participant(self):
-        """remove_participant removes user from thread"""
+    def test_set_users_unread_private_threads_sync(self):
+        """
+        set_users_unread_private_threads_sync sets sync_unread_private_threads
+        flag on user model to true
+        """
         User = get_user_model()
         User = get_user_model()
-        user = User.objects.create_user(
-            "Bob", "bob@boberson.com", "Pass.123")
-
-        add_owner(self.thread, user)
-        remove_participant(self.thread, user)
-
-        with self.assertRaises(ThreadParticipant.DoesNotExist):
-            self.thread.threadparticipant_set.get(user=user)
-
-        set_user_unread_private_threads_sync(user)
-        self.assertTrue(user.sync_unread_private_threads)
-
-        db_user = User.objects.get(pk=user.pk)
-        self.assertTrue(db_user.sync_unread_private_threads)
+        users = [
+            User.objects.create_user("Bob1", "bob1@boberson.com", "Pass.123"),
+            User.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123"),
+        ]
+
+        set_users_unread_private_threads_sync(users)
+        for user in users:
+            User.objects.get(pk=user.pk, sync_unread_private_threads=True)

+ 342 - 0
misago/threads/tests/test_privatethread_start_api.py

@@ -0,0 +1,342 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import json
+
+from django.contrib.auth import get_user_model
+from django.core import mail
+from django.urls 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.models import (
+    LIMITS_PRIVATE_THREAD_INVITES_TO_FOLLOWED, LIMITS_PRIVATE_THREAD_INVITES_TO_NOBODY)
+from misago.users.testutils import AuthenticatedUserTestCase
+
+from ..models import Thread, ThreadParticipant
+
+
+class StartPrivateThreadTests(AuthenticatedUserTestCase):
+    def setUp(self):
+        super(StartPrivateThreadTests, self).setUp()
+
+        self.category = Category.objects.private_threads()
+        self.api_link = reverse('misago:api:private-thread-list')
+
+        User = get_user_model()
+        self.other_user = get_user_model().objects.create_user(
+            'BobBoberson', 'bob@boberson.com', 'pass123')
+
+    def test_cant_start_thread_as_guest(self):
+        """user has to be authenticated to be able to post private thread"""
+        self.logout_user()
+
+        response = self.client.post(self.api_link)
+        self.assertEqual(response.status_code, 403)
+
+    def test_cant_use_private_threads(self):
+        """has no permission to see selected category"""
+        override_acl(self.user, {'can_use_private_threads': 0})
+
+        response = self.client.post(self.api_link)
+        self.assertContains(response, "You can't use private threads.", status_code=403)
+
+    def test_cant_start_private_thread(self):
+        """permission to start private thread is validated"""
+        override_acl(self.user, {'can_start_private_threads': 0})
+
+        response = self.client.post(self.api_link)
+        self.assertContains(response, "You can't start private threads.", status_code=403)
+
+    def test_empty_data(self):
+        """no data sent handling has no showstoppers"""
+        response = self.client.post(self.api_link, data={})
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'to': [
+                "You have to enter user names."
+            ],
+            'title':[
+                "You have to enter thread title."
+            ],
+            'post': [
+                "You have to enter a message."
+            ]
+        })
+
+    def test_title_is_validated(self):
+        """title is validated"""
+        response = self.client.post(self.api_link, data={
+            'to': [self.other_user.username],
+            'title': "------",
+            'post': "Lorem ipsum dolor met, sit amet elit!",
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'title': [
+                "Thread title should contain alpha-numeric characters."
+            ]
+        })
+
+    def test_post_is_validated(self):
+        """post is validated"""
+        response = self.client.post(self.api_link, data={
+            'to': [self.other_user.username],
+            'title': "Lorem ipsum dolor met",
+            'post': "a",
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'post': [
+                "Posted message should be at least 5 characters long (it has 1)."
+            ]
+        })
+
+    def test_cant_invite_self(self):
+        """api validates that you cant invite yourself to private thread"""
+        response = self.client.post(self.api_link, data={
+            'to': [self.user.username],
+            'title': "Lorem ipsum dolor met",
+            'post': "Lorem ipsum dolor.",
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'to': [
+                "You can't include yourself on the list of users to invite to new thread."
+            ]
+        })
+
+    def test_cant_invite_nonexisting(self):
+        """api validates that you cant invite nonexisting user to thread"""
+        response = self.client.post(self.api_link, data={
+            'to': ['Ab', 'Cd'],
+            'title': "Lorem ipsum dolor met",
+            'post': "Lorem ipsum dolor.",
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'to': [
+                "One or more users could not be found: ab, cd"
+            ]
+        })
+
+    def test_cant_invite_too_many(self):
+        """api validates that you cant invite too many users to thread"""
+        response = self.client.post(self.api_link, data={
+            'to': ['Username{}'.format(i) for i in range(50)],
+            'title': "Lorem ipsum dolor met",
+            'post': "Lorem ipsum dolor.",
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'to': [
+                "You can't add more than 3 users to private thread (you've added 50)."
+            ]
+        })
+
+    def test_cant_invite_blocking(self):
+        """api validates that you cant invite blocking user to thread"""
+        self.other_user.blocks.add(self.user)
+
+        response = self.client.post(self.api_link, data={
+            'to': [self.other_user.username],
+            'title': "Lorem ipsum dolor met",
+            'post': "Lorem ipsum dolor.",
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'to': [
+                "BobBoberson is blocking you."
+            ]
+        })
+
+        # allow us to bypass blocked check
+        override_acl(self.user, {'can_add_everyone_to_private_threads': 1})
+
+        response = self.client.post(self.api_link, data={
+            'to': [self.other_user.username],
+            'title': "-----",
+            'post': "Lorem ipsum dolor.",
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'title': [
+                "Thread title should contain alpha-numeric characters."
+            ]
+        })
+
+    def test_cant_invite_followers_only(self):
+        """api validates that you cant invite followers-only user to thread"""
+        self.other_user.limits_private_thread_invites_to = LIMITS_PRIVATE_THREAD_INVITES_TO_FOLLOWED
+        self.other_user.save()
+
+        response = self.client.post(self.api_link, data={
+            'to': [self.other_user.username],
+            'title': "Lorem ipsum dolor met",
+            'post': "Lorem ipsum dolor.",
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'to': [
+                "BobBoberson limits invitations to private threads to followed users."
+            ]
+        })
+
+        # allow us to bypass following check
+        override_acl(self.user, {'can_add_everyone_to_private_threads': 1})
+
+        response = self.client.post(self.api_link, data={
+            'to': [self.other_user.username],
+            'title': "-----",
+            'post': "Lorem ipsum dolor.",
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'title': [
+                "Thread title should contain alpha-numeric characters."
+            ]
+        })
+
+        # make user follow us
+        override_acl(self.user, {'can_add_everyone_to_private_threads': 0})
+        self.other_user.follows.add(self.user)
+
+        response = self.client.post(self.api_link, data={
+            'to': [self.other_user.username],
+            'title': "-----",
+            'post': "Lorem ipsum dolor.",
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'title': [
+                "Thread title should contain alpha-numeric characters."
+            ]
+        })
+
+    def test_cant_invite_anyone(self):
+        """api validates that you cant invite nobody user to thread"""
+        self.other_user.limits_private_thread_invites_to = LIMITS_PRIVATE_THREAD_INVITES_TO_NOBODY
+        self.other_user.save()
+
+        response = self.client.post(self.api_link, data={
+            'to': [self.other_user.username],
+            'title': "Lorem ipsum dolor met",
+            'post': "Lorem ipsum dolor.",
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'to': [
+                "BobBoberson is not allowing invitations to private threads."
+            ]
+        })
+
+        # allow us to bypass user preference check
+        override_acl(self.user, {'can_add_everyone_to_private_threads': 1})
+
+        response = self.client.post(self.api_link, data={
+            'to': [self.other_user.username],
+            'title': "-----",
+            'post': "Lorem ipsum dolor.",
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'title': [
+                "Thread title should contain alpha-numeric characters."
+            ]
+        })
+
+    def test_can_start_thread(self):
+        """endpoint creates new thread"""
+        response = self.client.post(self.api_link, data={
+            'to': [self.other_user.username],
+            'title': "Hello, I am test thread!",
+            'post': "Lorem ipsum dolor met!"
+        })
+        self.assertEqual(response.status_code, 200)
+
+        thread = self.user.thread_set.all()[:1][0]
+
+        response_json = response.json()
+        self.assertEqual(response_json['url'], thread.get_absolute_url())
+
+        response = self.client.get(thread.get_absolute_url())
+        self.assertContains(response, self.category.name)
+        self.assertContains(response, thread.title)
+        self.assertContains(response, "<p>Lorem ipsum dolor met!</p>")
+
+        self.reload_user()
+        self.assertEqual(self.user.threads, 1)
+        self.assertEqual(self.user.posts, 1)
+
+        self.assertEqual(thread.category_id, self.category.pk)
+        self.assertEqual(thread.title, "Hello, I am test thread!")
+        self.assertEqual(thread.starter_id, self.user.id)
+        self.assertEqual(thread.starter_name, self.user.username)
+        self.assertEqual(thread.starter_slug, self.user.slug)
+        self.assertEqual(thread.last_poster_id, self.user.id)
+        self.assertEqual(thread.last_poster_name, self.user.username)
+        self.assertEqual(thread.last_poster_slug, self.user.slug)
+
+        post = self.user.post_set.all()[:1][0]
+        self.assertEqual(post.category_id, self.category.pk)
+        self.assertEqual(post.original, 'Lorem ipsum dolor met!')
+        self.assertEqual(post.poster_id, self.user.id)
+        self.assertEqual(post.poster_name, self.user.username)
+
+        # thread has two participants
+        self.assertEqual(thread.participants.count(), 2)
+
+        # we are thread owner
+        ThreadParticipant.objects.get(
+            thread=thread,
+            user=self.user,
+            is_owner=True
+        )
+
+        # other user was added to thread
+        ThreadParticipant.objects.get(
+            thread=thread,
+            user=self.other_user,
+            is_owner=False
+        )
+
+        # other user has sync_unread_private_threads flag
+        User = get_user_model()
+        user_to_sync = User.objects.get(sync_unread_private_threads=True)
+        self.assertEqual(user_to_sync, self.other_user)
+
+        # notification about new private thread was sent to other user
+        self.assertEqual(len(mail.outbox), 1)
+        email = mail.outbox[-1]
+
+        self.assertIn(self.user.username, email.subject)
+        self.assertIn(thread.title, email.subject)
+
+        email_body = smart_str(email.body)
+
+        self.assertIn(self.user.username, email_body)
+        self.assertIn(thread.title, email_body)
+        self.assertIn(thread.get_absolute_url(), email_body)
+
+    def test_post_unicode(self):
+        """unicode characters can be posted"""
+        response = self.client.post(self.api_link, data={
+            'to': [self.other_user.username],
+            'title': "Brzęczyżczykiewicz",
+            'post': "Chrzążczyżewoszyce, powiat Łękółody."
+        })
+        self.assertEqual(response.status_code, 200)

+ 3 - 3
misago/threads/tests/test_privatethreadslists.py

@@ -3,7 +3,7 @@ from django.urls import reverse
 from misago.acl.testutils import override_acl
 from misago.acl.testutils import override_acl
 
 
 from .. import testutils
 from .. import testutils
-from ..participants import add_owner
+from ..models import ThreadParticipant
 from .test_privatethreads_api import PrivateThreadsApiTestCase
 from .test_privatethreads_api import PrivateThreadsApiTestCase
 
 
 
 
@@ -43,7 +43,7 @@ class PrivateThreadsApiTests(PrivateThreadsApiTestCase):
         hidden = testutils.post_thread(category=self.category, poster=self.user)
         hidden = testutils.post_thread(category=self.category, poster=self.user)
         reported = testutils.post_thread(category=self.category, poster=self.user)
         reported = testutils.post_thread(category=self.category, poster=self.user)
 
 
-        add_owner(visible, self.user)
+        ThreadParticipant.objects.add_participants(visible, [self.user])
 
 
         reported.has_reported_posts = True
         reported.has_reported_posts = True
         reported.save()
         reported.save()
@@ -103,7 +103,7 @@ class PrivateThreadsListTests(PrivateThreadsApiTestCase):
         hidden = testutils.post_thread(category=self.category, poster=self.user)
         hidden = testutils.post_thread(category=self.category, poster=self.user)
         reported = testutils.post_thread(category=self.category, poster=self.user)
         reported = testutils.post_thread(category=self.category, poster=self.user)
 
 
-        add_owner(visible, self.user)
+        ThreadParticipant.objects.add_participants(visible, [self.user])
 
 
         reported.has_reported_posts = True
         reported.has_reported_posts = True
         reported.save()
         reported.save()

+ 1 - 2
misago/threads/tests/test_thread_model.py

@@ -314,8 +314,7 @@ class ThreadModelTests(TestCase):
         user_b = User.objects.create_user(
         user_b = User.objects.create_user(
             "Weebl", "weebl@weeblson.com", "Pass.123")
             "Weebl", "weebl@weeblson.com", "Pass.123")
 
 
-        ThreadParticipant.objects.add_participant(self.thread, user_a)
-        ThreadParticipant.objects.add_participant(self.thread, user_b)
+        ThreadParticipant.objects.add_participants(self.thread, [user_a, user_b])
         self.assertEqual(self.thread.participants.count(), 2)
         self.assertEqual(self.thread.participants.count(), 2)
 
 
         user_a.delete()
         user_a.delete()

+ 6 - 9
misago/threads/tests/test_thread_start_api.py

@@ -1,9 +1,7 @@
 # -*- coding: utf-8 -*-
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
-import json
-
-from django.utils.encoding import smart_str
+from django.urls import reverse
 
 
 from misago.acl.testutils import override_acl
 from misago.acl.testutils import override_acl
 from misago.categories.models import THREADS_ROOT_NAME, Category
 from misago.categories.models import THREADS_ROOT_NAME, Category
@@ -20,8 +18,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
         threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
         threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
 
 
         self.category = Category.objects.get(slug='first-category')
         self.category = Category.objects.get(slug='first-category')
-
-        self.api_link = '/api/threads/'
+        self.api_link = reverse('misago:api:thread-list')
 
 
     def override_acl(self, extra_acl=None):
     def override_acl(self, extra_acl=None):
         new_acl = self.user.acl
         new_acl = self.user.acl
@@ -116,7 +113,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
 
 
         response = self.client.post(self.api_link, data={})
         response = self.client.post(self.api_link, data={})
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(json.loads(smart_str(response.content)), {
+        self.assertEqual(response.json(), {
             'category': [
             'category': [
                 "You have to select category to post thread in."
                 "You have to select category to post thread in."
             ],
             ],
@@ -139,7 +136,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
         })
         })
 
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(json.loads(smart_str(response.content)), {
+        self.assertEqual(response.json(), {
             'title': [
             'title': [
                 "Thread title should contain alpha-numeric characters."
                 "Thread title should contain alpha-numeric characters."
             ]
             ]
@@ -156,7 +153,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
         })
         })
 
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(json.loads(smart_str(response.content)), {
+        self.assertEqual(response.json(), {
             'post': [
             'post': [
                 "Posted message should be at least 5 characters long (it has 1)."
                 "Posted message should be at least 5 characters long (it has 1)."
             ]
             ]
@@ -174,7 +171,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
 
 
         thread = self.user.thread_set.all()[:1][0]
         thread = self.user.thread_set.all()[:1][0]
 
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['url'], thread.get_absolute_url())
         self.assertEqual(response_json['url'], thread.get_absolute_url())
 
 
         self.override_acl()
         self.override_acl()

+ 38 - 42
misago/threads/tests/test_threadparticipant_model.py

@@ -41,60 +41,56 @@ class ThreadParticipantTests(TestCase):
         self.thread.last_post = post
         self.thread.last_post = post
         self.thread.save()
         self.thread.save()
 
 
-    def test_delete_participant(self):
-        """delete_participant deletes participant from thread"""
+    def test_set_owner(self):
+        """set_owner makes user thread owner"""
         User = get_user_model()
         User = get_user_model()
-        user = User.objects.create_user(
-            "Bob", "bob@boberson.com", "Pass.123")
-        other_user = User.objects.create_user(
-            "Bob2", "bob2@boberson.com", "Pass.123")
+        user = User.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
+        other_user = User.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123")
 
 
-        ThreadParticipant.objects.add_participant(self.thread, user)
-        ThreadParticipant.objects.add_participant(self.thread, other_user)
+        ThreadParticipant.objects.set_owner(self.thread, user)
+        self.assertEqual(self.thread.participants.count(), 1)
+
+        participant = ThreadParticipant.objects.get(thread=self.thread, user=user)
+        self.assertTrue(participant.is_owner)
+        self.assertEqual(user, participant.user)
+
+        # threads can't have more than one owner
+        ThreadParticipant.objects.set_owner(self.thread, other_user)
         self.assertEqual(self.thread.participants.count(), 2)
         self.assertEqual(self.thread.participants.count(), 2)
 
 
-        ThreadParticipant.objects.remove_participant(self.thread, user)
-        self.assertEqual(self.thread.participants.count(), 1)
+        participant = ThreadParticipant.objects.get(thread=self.thread, user=user)
+        self.assertFalse(participant.is_owner)
 
 
-        with self.assertRaises(ThreadParticipant.DoesNotExist):
-            participation = ThreadParticipant.objects.get(
-                thread=self.thread, user=user)
+        self.assertEqual(ThreadParticipant.objects.filter(is_owner=True).count(), 1)
 
 
-    def test_add_participant(self):
+    def test_add_participants(self):
         """add_participant adds participant to thread"""
         """add_participant adds participant to thread"""
         User = get_user_model()
         User = get_user_model()
-        user = User.objects.create_user(
-            "Bob", "bob@boberson.com", "Pass.123")
-
-        ThreadParticipant.objects.add_participant(self.thread, user)
-        self.assertEqual(self.thread.participants.count(), 1)
-
-        participation = ThreadParticipant.objects.get(
-            thread=self.thread, user=user)
-        self.assertFalse(participation.is_owner)
+        users = [
+            User.objects.create_user("Bob", "bob@boberson.com", "Pass.123"),
+            User.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123"),
+        ]
 
 
-        ThreadParticipant.objects.add_participant(self.thread, user)
+        ThreadParticipant.objects.add_participants(self.thread, users)
         self.assertEqual(self.thread.participants.count(), 2)
         self.assertEqual(self.thread.participants.count(), 2)
 
 
-    def test_set_owner(self):
-        """set_owner makes user thread owner"""
-        User = get_user_model()
-        user = User.objects.create_user(
-            "Bob", "bob@boberson.com", "Pass.123")
+        for user in users:
+            participant = ThreadParticipant.objects.get(thread=self.thread, user=user)
+            self.assertFalse(participant.is_owner)
 
 
-        ThreadParticipant.objects.set_owner(self.thread, user)
-        self.assertEqual(self.thread.participants.count(), 1)
-
-        participation = ThreadParticipant.objects.get(
-            thread=self.thread, user=user)
-        self.assertTrue(participation.is_owner)
-        self.assertEqual(user, participation.user)
+    def test_remove_participant(self):
+        """remove_participant deletes participant from thread"""
+        User = get_user_model()
+        user = User.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
+        other_user = User.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123")
 
 
-        other_user = User.objects.create_user(
-            "Bob2", "bob2@boberson.com", "Pass.123")
-        ThreadParticipant.objects.set_owner(self.thread, other_user)
+        ThreadParticipant.objects.add_participants(self.thread, [user])
+        ThreadParticipant.objects.add_participants(self.thread, [other_user])
         self.assertEqual(self.thread.participants.count(), 2)
         self.assertEqual(self.thread.participants.count(), 2)
 
 
-        participation = ThreadParticipant.objects.get(
-            thread=self.thread, user=user)
-        self.assertFalse(participation.is_owner)
+        ThreadParticipant.objects.remove_participant(self.thread, user)
+        self.assertEqual(self.thread.participants.count(), 1)
+
+        with self.assertRaises(ThreadParticipant.DoesNotExist):
+            participant = ThreadParticipant.objects.get(
+                thread=self.thread, user=user)

+ 2 - 2
misago/threads/viewmodels/thread.py

@@ -9,7 +9,7 @@ from misago.core.viewmodel import ViewModel as BaseViewModel
 from misago.readtracker.threadstracker import make_read_aware
 from misago.readtracker.threadstracker import make_read_aware
 
 
 from ..models import Poll, Thread
 from ..models import Poll, Thread
-from ..participants import make_thread_participants_aware
+from ..participants import make_participants_aware
 from ..permissions.privatethreads import allow_see_private_thread
 from ..permissions.privatethreads import allow_see_private_thread
 from ..permissions.threads import allow_see_thread
 from ..permissions.threads import allow_see_thread
 from ..serializers import ThreadSerializer
 from ..serializers import ThreadSerializer
@@ -129,7 +129,7 @@ class PrivateThread(ViewModel):
             category__tree_id=trees_map.get_tree_id_for_root(PRIVATE_THREADS_ROOT_NAME)
             category__tree_id=trees_map.get_tree_id_for_root(PRIVATE_THREADS_ROOT_NAME)
         )
         )
 
 
-        make_thread_participants_aware(request.user, thread)
+        make_participants_aware(request.user, thread)
         allow_see_private_thread(request.user, thread)
         allow_see_private_thread(request.user, thread)
 
 
         if slug:
         if slug: