Rafał Pitoń 8 лет назад
Родитель
Сommit
4cf38b509b

+ 35 - 1
misago/threads/api/threadendpoints/patch.py

@@ -1,3 +1,4 @@
+from django.contrib.auth import get_user_model
 from django.core.exceptions import PermissionDenied, ValidationError
 from django.utils import six
 from django.utils.translation import gettext as _
@@ -9,8 +10,13 @@ from misago.categories.serializers import CategorySerializer
 from misago.core.apipatch import ApiPatch
 from misago.core.shortcuts import get_int_or_404, get_object_or_404
 
+from ...models import ThreadParticipant
 from ...moderation import threads as moderation
-from ...permissions import allow_start_thread
+from ...participants import add_participant, remove_participant
+from ...permissions import (
+    allow_start_thread, allow_takeover, allow_add_participants,
+    allow_add_participant, allow_remove_participants)
+from ...serializers import ThreadParticipantSerializer
 from ...utils import add_categories_to_items
 from ...validators import validate_title
 
@@ -192,6 +198,34 @@ def patch_subscribtion(request, thread, value):
 thread_patch_dispatcher.replace('subscription', patch_subscribtion)
 
 
+def patch_add_participants(request, thread, value):
+    allow_add_participants(request.user, thread)
+
+    User = get_user_model()
+    try:
+        participant = User.objects.get(slug=six.text_type(value).strip().lower())
+    except User.DoesNotExist:
+        raise PermissionDenied("No user with such name exists.")
+
+    if participant in [p.user for p in thread.participants_list]:
+        raise PermissionDenied("This user is already thread participant.")
+
+    allow_add_participant(request.user, participant)
+    add_participant(request, thread, participant)
+
+    return {
+        'participant': ThreadParticipantSerializer(
+            ThreadParticipant(user=participant, is_owner=False)
+        ).data
+    }
+thread_patch_dispatcher.add('participants', patch_add_participants)
+
+
+def patch_remove_participants(request, thread, value):
+    pass
+thread_patch_dispatcher.remove('participants', patch_remove_participants)
+
+
 def thread_patch_endpoint(request, thread):
     old_title = thread.title
     old_is_hidden = thread.is_hidden

+ 8 - 1
misago/threads/participants.py

@@ -3,6 +3,7 @@ from django.utils.translation import ugettext as _
 
 from misago.core.mail import build_mail, send_messages
 
+from .events import record_event
 from .models import ThreadParticipant
 from .signals import remove_thread_participant
 
@@ -41,9 +42,15 @@ def set_users_unread_private_threads_sync(users):
 
 def add_participant(request, thread, user):
     """
-    Shortcut for adding single participant to thread
+    Adds single participant to thread, registers this on the event
     """
     add_participants(request, thread, [user])
+    record_event(request, thread, 'added_participant', {
+        'user': {
+            'username': user.username,
+            'url': user.get_absolute_url(),
+        }
+    })
 
 
 def add_participants(request, thread, users):

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

@@ -7,9 +7,11 @@ from django.utils.translation import ugettext_lazy as _
 from misago.acl import add_acl, algebra
 from misago.acl.decorators import return_boolean
 from misago.acl.models import Role
-from misago.categories.models import Category
+from misago.categories.models import PRIVATE_THREADS_ROOT_NAME, Category
 from misago.core import forms
 
+from ..models import Thread
+
 
 __all__ = [
     'allow_use_private_threads',
@@ -18,6 +20,14 @@ __all__ = [
     'can_see_private_thread',
     'allow_see_private_post',
     'can_see_private_post',
+    'allow_takeover',
+    'can_takeover',
+    'allow_add_participants',
+    'can_add_participants',
+    'allow_remove_participants',
+    'can_remove_participants',
+    'allow_add_participant',
+    'can_add_participant',
     'allow_message_user',
     'can_message_user',
     'exclude_invisible_private_threads',
@@ -143,6 +153,25 @@ def build_acl(acl, roles, key_name):
     return new_acl
 
 
+def add_acl_to_thread(user, thread):
+    if thread.thread_type.root_name != PRIVATE_THREADS_ROOT_NAME:
+        return
+
+    if not hasattr(thread, 'participant'):
+        thread.participants_list = []
+        thread.participant = None
+
+    thread.acl.update({
+        'can_takeover': can_takeover(user, thread),
+        'can_add_participants': can_add_participants(user, thread),
+        'can_remove_participants': can_remove_participants(user, thread)
+    })
+
+
+def register_with(registry):
+    registry.acl_annotator(Thread, add_acl_to_thread)
+
+
 """
 ACL tests
 """
@@ -168,7 +197,7 @@ can_see_private_thread = return_boolean(allow_see_private_thread)
 
 
 def allow_see_private_post(user, target):
-    can_see_reported = user.acl.get('can_moderate_private_threads')
+    can_see_reported = user.acl['can_moderate_private_threads']
     if not (can_see_reported and target.thread.has_reported_posts):
         for participant in target.thread.participants_list:
             if participant.user == user and participant.is_removed:
@@ -177,15 +206,38 @@ def allow_see_private_post(user, target):
 can_see_private_post = return_boolean(allow_see_private_post)
 
 
-def allow_message_user(user, target):
-    allow_use_private_threads(user)
+def allow_takeover(user, target):
+    if target.participant and target.participant.is_owner:
+        raise PermissionDenied(
+            _("You are already this thread's owner."))
+    if not user.acl['can_moderate_private_threads']:
+        raise PermissionDenied(
+            _("Only private threads moderators can take over private threads."))
+can_takeover = return_boolean(allow_takeover)
 
-    if user == target:
-        raise PermissionDenied(_("You can't message yourself."))
 
-    if not user.acl['can_start_private_threads']:
-        raise PermissionDenied(_("You can't start private threads."))
+def allow_add_participants(user, target):
+    if not target.participant or not target.participant.is_owner:
+        raise PermissionDenied(
+            _("You have to be thread owner to add new participants to it."))
+
+    max_participants = user.acl['max_private_thread_participants']
+    current_participants = len(target.participants_list) - 1
 
+    if current_participants >= max_participants :
+        raise PermissionDenied(
+            _("You can't add any more new users to this thread."))
+can_add_participants = return_boolean(allow_add_participants)
+
+
+def allow_remove_participants(user, target):
+    if not target.participant or not target.participant.is_owner:
+        raise PermissionDenied(
+            _("You have to be thread owner to remove participants from it."))
+can_remove_participants = return_boolean(allow_remove_participants)
+
+
+def allow_add_participant(user, target):
     message_format = {'user': target.username}
 
     if not can_use_private_threads(target):
@@ -205,6 +257,19 @@ def allow_message_user(user, target):
     if target.can_be_messaged_by_followed and not target.is_following(user):
         message = _("%(user)s limits invitations to private threads to followed users.")
         raise PermissionDenied(message % message_format)
+can_add_participant = return_boolean(allow_add_participant)
+
+
+def allow_message_user(user, target):
+    allow_use_private_threads(user)
+
+    if user == target:
+        raise PermissionDenied(_("You can't message yourself."))
+
+    if not user.acl['can_start_private_threads']:
+        raise PermissionDenied(_("You can't start private threads."))
+
+    allow_add_participant(user, target)
 can_message_user = return_boolean(allow_message_user)
 
 

+ 123 - 0
misago/threads/tests/test_privatethread_patch_api.py

@@ -0,0 +1,123 @@
+import json
+
+from django.contrib.auth import get_user_model
+from django.core import mail
+
+from misago.acl.testutils import override_acl
+
+from .. import testutils
+from ..models import ThreadParticipant
+from .test_privatethreads import PrivateThreadsTestCase
+
+
+class PrivateThreadPatchApiTestCase(PrivateThreadsTestCase):
+    def setUp(self):
+        super(PrivateThreadPatchApiTestCase, self).setUp()
+
+        self.thread = testutils.post_thread(self.category, poster=self.user)
+        self.api_link = self.thread.get_api_url()
+
+    def patch(self, api_link, ops):
+        return self.client.patch(
+            api_link, json.dumps(ops), content_type="application/json")
+
+
+class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
+    def setUp(self):
+        super(PrivateThreadAddParticipantApiTests, self).setUp()
+
+        User = get_user_model()
+        self.other_user = get_user_model().objects.create_user(
+            'BobBoberson', 'bob@boberson.com', 'pass123')
+
+    def test_add_participant_not_owner(self):
+        """non-owner can't add participant"""
+        ThreadParticipant.objects.add_participants(self.thread, [self.user])
+
+        response = self.patch(self.api_link, [
+            {'op': 'add', 'path': 'participants', 'value': self.user.username}
+        ])
+        self.assertContains(
+            response, "be thread owner to add new participants to it", status_code=400)
+
+    def test_add_nonexistant_user(self):
+        """can't user two times"""
+        ThreadParticipant.objects.set_owner(self.thread, self.user)
+
+        response = self.patch(self.api_link, [
+            {'op': 'add', 'path': 'participants', 'value': 'InvalidUser'}
+        ])
+
+        self.assertContains(response, "No user with such name exists.", status_code=400)
+
+    def test_add_already_participant(self):
+        """can't add user that is already participant"""
+        ThreadParticipant.objects.set_owner(self.thread, self.user)
+
+        response = self.patch(self.api_link, [
+            {'op': 'add', 'path': 'participants', 'value': self.user.username}
+        ])
+
+        self.assertContains(
+            response, "This user is already thread participant", status_code=400)
+
+    def test_add_blocking_user(self):
+        """can't add user that is already participant"""
+        ThreadParticipant.objects.set_owner(self.thread, self.user)
+        self.other_user.blocks.add(self.user)
+
+        response = self.patch(self.api_link, [
+            {'op': 'add', 'path': 'participants', 'value': self.other_user.username}
+        ])
+        self.assertContains(response, "BobBoberson is blocking you.", status_code=400)
+
+    def test_add_too_many_users(self):
+        """can't add user that is already participant"""
+        ThreadParticipant.objects.set_owner(self.thread, self.user)
+
+        User = get_user_model()
+        for i in range(self.user.acl['max_private_thread_participants']):
+            user = User.objects.create_user(
+                'User{}'.format(i), 'user{}@example.com'.format(i), 'Pass.123')
+            ThreadParticipant.objects.add_participants(self.thread, [user])
+
+        response = self.patch(self.api_link, [
+            {'op': 'add', 'path': 'participants', 'value': self.other_user.username}
+        ])
+        self.assertContains(
+            response, "You can't add any more new users to this thread.", status_code=400)
+
+    def test_add_user(self):
+        """adding user to thread add user to thread as participant, sets event and emails him"""
+        ThreadParticipant.objects.set_owner(self.thread, self.user)
+
+        response = self.patch(self.api_link, [
+            {'op': 'add', 'path': 'participants', 'value': self.other_user.username}
+        ])
+        self.assertEqual(response.json()['participant'], {
+            'id': self.other_user.id,
+            'username': self.other_user.username,
+            'avatar_hash': self.other_user.avatar_hash,
+            'url': self.other_user.get_absolute_url(),
+            'is_owner': False,
+        })
+
+        # event was set on thread
+        event = self.thread.post_set.order_by('id').last()
+        self.assertTrue(event.is_event)
+        self.assertTrue(event.event_type, 'added_participant')
+
+        # 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(self.thread.title, email.subject)
+
+
+class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
+    pass
+
+
+class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
+    pass

+ 22 - 22
misago/threads/tests/test_thread_patch_api.py

@@ -1,7 +1,6 @@
 import json
 
 from django.utils import six
-from django.utils.encoding import smart_str
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
@@ -11,7 +10,8 @@ from .test_threads_api import ThreadsApiTestCase
 
 class ThreadPatchApiTestCase(ThreadsApiTestCase):
     def patch(self, api_link, ops):
-        return self.client.patch(api_link, json.dumps(ops), content_type="application/json")
+        return self.client.patch(
+            api_link, json.dumps(ops), content_type="application/json")
 
 
 class ThreadAddAclApiTests(ThreadPatchApiTestCase):
@@ -22,7 +22,7 @@ class ThreadAddAclApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertTrue(response_json['acl'])
 
     def test_add_acl_false(self):
@@ -32,7 +32,7 @@ class ThreadAddAclApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertIsNone(response_json['acl'])
 
 
@@ -62,7 +62,7 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             "You don't have permission to edit this thread.")
 
@@ -77,7 +77,7 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             "Thread title should be at least 5 characters long (it has 2).")
 
@@ -128,7 +128,7 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             "You don't have permission to pin this thread globally.")
 
@@ -152,7 +152,7 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             "You don't have permission to change this thread's weight.")
 
@@ -206,7 +206,7 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             "You don't have permission to change this thread's weight.")
 
@@ -230,7 +230,7 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             "You don't have permission to change this thread's weight.")
 
@@ -293,7 +293,7 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category_b.pk)
 
-        reponse_json = json.loads(smart_str(response.content))
+        reponse_json = response.json()
         self.assertEqual(reponse_json['category'], self.category_b.pk)
         self.assertEqual(reponse_json['top_category'], None)
 
@@ -322,7 +322,7 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category_b.pk)
 
-        reponse_json = json.loads(smart_str(response.content))
+        reponse_json = response.json()
         self.assertEqual(reponse_json['category'], self.category_b.pk)
         self.assertEqual(reponse_json['top_category'], self.category.pk)
 
@@ -338,7 +338,7 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             "You don't have permission to move this thread.")
 
@@ -361,7 +361,7 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], 'NOT FOUND')
 
         self.override_other_acl({})
@@ -383,7 +383,7 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             'You don\'t have permission to browse "Category B" contents.')
 
@@ -406,7 +406,7 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             "You can't move thread to the category it's already in.")
 
@@ -422,7 +422,7 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['category'], self.category.pk)
 
     def test_thread_top_flatten_categories(self):
@@ -442,7 +442,7 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['top_category'], self.category.pk)
         self.assertEqual(response_json['category'], self.category_b.pk)
 
@@ -493,7 +493,7 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             "You don't have permission to close this thread.")
 
@@ -517,7 +517,7 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             "You don't have permission to open this thread.")
 
@@ -554,7 +554,7 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             "Content approval can't be reversed.")
 
@@ -617,7 +617,7 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0],
             "You don't have permission to hide this thread.")
 

+ 3 - 1
misago/threads/threadtypes/privatethread.py

@@ -1,11 +1,13 @@
 from django.urls import reverse
 from django.utils.translation import ugettext_lazy as _
 
+from misago.categories.models import PRIVATE_THREADS_ROOT_NAME
+
 from . import ThreadType
 
 
 class PrivateThread(ThreadType):
-    root_name = 'private_threads'
+    root_name = PRIVATE_THREADS_ROOT_NAME
 
     def get_category_name(self, category):
         return _('Private Threads')

+ 3 - 1
misago/threads/threadtypes/thread.py

@@ -1,11 +1,13 @@
 from django.urls import reverse
 from django.utils.translation import ugettext_lazy as _
 
+from misago.categories.models import THREADS_ROOT_NAME
+
 from . import ThreadType
 
 
 class Thread(ThreadType):
-    root_name = 'root_category'
+    root_name = THREADS_ROOT_NAME
 
     def get_category_name(self, category):
         if category.level: