diff --git a/seahub/file_participants/__init__.py b/seahub/file_participants/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/seahub/file_participants/models.py b/seahub/file_participants/models.py new file mode 100644 index 0000000000..58bc6791f9 --- /dev/null +++ b/seahub/file_participants/models.py @@ -0,0 +1,64 @@ +# Copyright (c) 2012-2019 Seafile Ltd. +import os + +import logging +from django.db import models +from seahub.tags.models import FileUUIDMap +from seahub.base.fields import LowerCaseCharField + +# Get an instance of a logger +logger = logging.getLogger(__name__) + + +class FileParticipantManager(models.Manager): + + def _get_file_uuid_map(self, repo_id, file_path): + parent_path = os.path.dirname(file_path) + item_name = os.path.basename(file_path) + + file_uuid_map = FileUUIDMap.objects.get_or_create_fileuuidmap( + repo_id, parent_path, item_name, False) + + return file_uuid_map + + def add_by_file_path_and_username(self, repo_id, file_path, username): + uuid = self._get_file_uuid_map(repo_id, file_path) + if self.filter(uuid=uuid, username=username).exists(): + return self.filter(uuid=uuid, username=username)[0] + + obj = self.model(uuid=uuid, username=username) + obj.save(using=self._db) + + return obj + + def get_by_file_path_and_username(self, repo_id, file_path, username): + uuid = self._get_file_uuid_map(repo_id, file_path) + try: + obj = self.get(uuid=uuid, username=username) + return obj + except self.model.DoesNotExist: + return None + + def delete_by_file_path_and_username(self, repo_id, file_path, username): + uuid = self._get_file_uuid_map(repo_id, file_path) + self.filter(uuid=uuid, username=username).delete() + + def get_by_file_path(self, repo_id, file_path): + uuid = self._get_file_uuid_map(repo_id, file_path) + objs = self.filter(uuid=uuid) + + return objs + + +class FileParticipant(models.Model): + """ + Model used to record file participants. + """ + uuid = models.ForeignKey(FileUUIDMap, on_delete=models.CASCADE) + username = LowerCaseCharField(max_length=255) + + objects = FileParticipantManager() + + class Meta: + """Meta data""" + unique_together = ('uuid', 'username') diff --git a/seahub/file_participants/utils.py b/seahub/file_participants/utils.py new file mode 100644 index 0000000000..2139478c84 --- /dev/null +++ b/seahub/file_participants/utils.py @@ -0,0 +1,12 @@ +from .models import FileParticipant + + +def list_file_participants_username(repo_id, path): + """ return participants username list + """ + username_list = [] + file_participant_queryset = FileParticipant.objects.get_by_file_path(repo_id, path) + for participant in file_participant_queryset: + username_list.append(participant.username) + + return username_list diff --git a/seahub/file_participants/views.py b/seahub/file_participants/views.py new file mode 100644 index 0000000000..b62310ca8b --- /dev/null +++ b/seahub/file_participants/views.py @@ -0,0 +1,144 @@ +# Copyright (c) 2012-2019 Seafile Ltd. +# -*- coding: utf-8 -*- +import logging + +from rest_framework import status +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from seaserv import seafile_api + +from seahub.api2.authentication import TokenAuthentication +from seahub.api2.permissions import IsRepoAccessible +from seahub.api2.throttling import UserRateThrottle +from seahub.api2.utils import api_error, get_user_common_info +from .models import FileParticipant +from seahub.utils import normalize_file_path, is_valid_username +from seahub.views import check_folder_permission +from pysearpc import SearpcError +from seahub.base.accounts import User + +logger = logging.getLogger(__name__) + + +class FileParticipantsView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated, IsRepoAccessible) + throttle_classes = (UserRateThrottle,) + + def get(self, request, repo_id, format=None): + """List all participants of a file. + """ + # argument check + path = request.GET.get('path', '/').rstrip('/') + if not path: + return api_error(status.HTTP_400_BAD_REQUEST, 'Invalid path.') + path = normalize_file_path(path) + + # resource check + try: + file_id = seafile_api.get_file_id_by_path(repo_id, path) + except SearpcError as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal error.') + if not file_id: + return api_error(status.HTTP_404_NOT_FOUND, 'File not found.') + + # permission check + if not check_folder_permission(request, repo_id, '/'): + return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.') + + # main + participant_list = [] + participant_queryset = FileParticipant.objects.get_by_file_path(repo_id, path) + + for participant in participant_queryset: + participant_info = get_user_common_info(participant.username) + participant_list.append(participant_info) + + return Response({'participant_list': participant_list}) + + def post(self, request, repo_id, format=None): + """Post a participant of a file. + """ + # argument check + path = request.GET.get('path', '/').rstrip('/') + if not path: + return api_error(status.HTTP_400_BAD_REQUEST, 'Invalid path.') + path = normalize_file_path(path) + + email = request.data.get('email', '').lower() + if not email: + email = request.user.username + + if not is_valid_username(email): + return api_error(status.HTTP_400_BAD_REQUEST, 'Invalid email.') + + # resource check + try: + file_id = seafile_api.get_file_id_by_path(repo_id, path) + except SearpcError as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal error.') + if not file_id: + return api_error(status.HTTP_404_NOT_FOUND, 'File not found.') + + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + return api_error(status.HTTP_404_NOT_FOUND, 'User %s not found.' % email) + + # permission check + if not check_folder_permission(request, repo_id, '/'): + return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.') + + request.user = user + if not check_folder_permission(request, repo_id, '/'): + return api_error(status.HTTP_403_FORBIDDEN, 'User %s permission denied.' % email) + + # main + if FileParticipant.objects.get_by_file_path_and_username(repo_id, path, email): + return api_error(status.HTTP_400_BAD_REQUEST, 'Participant %s already exists.' % email) + + FileParticipant.objects.add_by_file_path_and_username(repo_id, path, email) + participant = get_user_common_info(email) + + return Response(participant, status=201) + + +class FileParticipantView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated, IsRepoAccessible) + throttle_classes = (UserRateThrottle,) + + def delete(self, request, repo_id, format=None): + """Delete a participant + """ + # argument check + path = request.GET.get('path', '/').rstrip('/') + if not path: + return api_error(status.HTTP_400_BAD_REQUEST, 'Invalid path.') + path = normalize_file_path(path) + + email = request.data.get('email', '').lower() + if not email or not is_valid_username(email): + return api_error(status.HTTP_400_BAD_REQUEST, 'Invalid email.') + + # resource check + try: + file_id = seafile_api.get_file_id_by_path(repo_id, path) + except SearpcError as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal error.') + if not file_id: + return api_error(status.HTTP_404_NOT_FOUND, 'File not found.') + + # permission check + if not check_folder_permission(request, repo_id, '/'): + return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.') + + # main + FileParticipant.objects.delete_by_file_path_and_username(repo_id, path, email) + + return Response({'success': True}) diff --git a/seahub/notifications/models.py b/seahub/notifications/models.py index 5b3ac48d98..c3b353420d 100644 --- a/seahub/notifications/models.py +++ b/seahub/notifications/models.py @@ -25,6 +25,7 @@ from seahub.utils import normalize_cache_key from seahub.utils.timeutils import datetime_to_isoformat_timestr from seahub.constants import HASH_URLS from seahub.drafts.models import DraftReviewer +from seahub.file_participants.utils import list_file_participants_username # Get an instance of a logger logger = logging.getLogger(__name__) @@ -979,14 +980,15 @@ def add_user_to_group_cb(sender, **kwargs): @receiver(comment_file_successful) def comment_file_successful_cb(sender, **kwargs): + """ send notification to file participants + """ repo = kwargs['repo'] repo_owner = kwargs['repo_owner'] file_path = kwargs['file_path'] comment = kwargs['comment'] author = kwargs['author'] - notify_users = get_repo_shared_users(repo.id, repo_owner) - notify_users.append(repo_owner) + notify_users = list_file_participants_username(repo.id, file_path) notify_users = [x for x in notify_users if x != author] for u in notify_users: detail = file_comment_msg_to_json(repo.id, file_path, author, comment) diff --git a/seahub/settings.py b/seahub/settings.py index 206511865d..63d7a105c0 100644 --- a/seahub/settings.py +++ b/seahub/settings.py @@ -259,6 +259,7 @@ INSTALLED_APPS = ( 'seahub.related_files', 'seahub.work_weixin', 'seahub.dtable', + 'seahub.file_participants', ) # Enable or disable view File Scan diff --git a/seahub/urls.py b/seahub/urls.py index 6fe7a6ea2f..3a3bcd39e8 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -143,6 +143,7 @@ from seahub.api2.endpoints.admin.file_scan_records import AdminFileScanRecords from seahub.api2.endpoints.admin.notifications import AdminNotificationsView from seahub.api2.endpoints.admin.work_weixin import AdminWorkWeixinDepartments, \ AdminWorkWeixinDepartmentMembers, AdminWorkWeixinUsersBatch +from seahub.file_participants.views import FileParticipantsView, FileParticipantView urlpatterns = [ url(r'^accounts/', include('seahub.base.registration_urls')), @@ -353,6 +354,8 @@ urlpatterns = [ url(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/file-tags/$', RepoFileTagsView.as_view(), name='api-v2.1-file-tags'), url(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/file-tags/(?P\d+)/$', RepoFileTagView.as_view(), name='api-v2.1-file-tag'), url(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/tagged-files/(?P\d+)/$', TaggedFilesView.as_view(), name='api-v2.1-tagged-files'), + url(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/file/participants/$', FileParticipantsView.as_view(), name='api-v2.1-file-participants'), + url(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/file/participant/$', FileParticipantView.as_view(), name='api-v2.1-file-participant'), # user::related-files url(r'^api/v2.1/related-files/$', RelatedFilesView.as_view(), name='api-v2.1-related-files'), diff --git a/tests/api/endpoints/test_file_comments.py b/tests/api/endpoints/test_file_comments.py index 8bf7869ffd..9d38051c5c 100644 --- a/tests/api/endpoints/test_file_comments.py +++ b/tests/api/endpoints/test_file_comments.py @@ -7,6 +7,7 @@ from seaserv import seafile_api, ccnet_api from seahub.base.models import FileComment from seahub.notifications.models import UserNotification from seahub.test_utils import BaseTestCase +from seahub.file_participants.models import FileParticipant class FileCommentsTest(BaseTestCase): def setUp(self): @@ -86,12 +87,13 @@ class FileCommentsTest(BaseTestCase): }) self.assertEqual(403, resp.status_code) - def test_can_notify_others(self): + def test_can_notify_participant(self): assert len(UserNotification.objects.all()) == 0 - username = self.user.username - seafile_api.share_repo(self.repo.id, username, - self.admin.username, 'rw') + # share repo and add participant + seafile_api.share_repo(self.repo.id, self.user.username, self.admin.username, 'rw') + FileParticipant.objects.add_by_file_path_and_username( + repo_id=self.repo.id, file_path=self.file, username=self.admin.username) resp = self.client.post(self.endpoint, { 'comment': 'new comment' @@ -100,28 +102,3 @@ class FileCommentsTest(BaseTestCase): assert len(UserNotification.objects.all()) == 1 assert UserNotification.objects.all()[0].to_user == self.admin.username - - def test_can_notify_others_including_group(self): - self.logout() - self.login_as(self.tmp_user) - - assert len(UserNotification.objects.all()) == 0 - - # share repo to tmp_user - username = self.user.username - seafile_api.share_repo(self.repo.id, username, - self.tmp_user.username, 'rw') - - # share repo to group(owner, admin) - ccnet_api.group_add_member(self.group.id, username, - self.admin.username) - seafile_api.set_group_repo(self.repo.id, self.group.id, - username, 'rw') - - # tmp_user comment a file - resp = self.client.post(self.endpoint, { - 'comment': 'new comment' - }) - self.assertEqual(201, resp.status_code) - - assert len(UserNotification.objects.all()) == 2 diff --git a/tests/seahub/file_participant/__init__.py b/tests/seahub/file_participant/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/seahub/file_participant/test_file_participant.py b/tests/seahub/file_participant/test_file_participant.py new file mode 100644 index 0000000000..fbeccb6cab --- /dev/null +++ b/tests/seahub/file_participant/test_file_participant.py @@ -0,0 +1,48 @@ +import json + +from seaserv import seafile_api +from django.core.urlresolvers import reverse +from seahub.file_participants.models import FileParticipant +from seahub.test_utils import BaseTestCase +from tests.common.utils import randstring + + +class FileParticipantTest(BaseTestCase): + def setUp(self): + self.tmp_user = self.create_user() + + self.login_as(self.user) + self.url = reverse('api-v2.1-file-participant', args=[self.repo.id]) + '?path=' + self.file + # share repo and add participant + seafile_api.share_repo(self.repo.id, self.user.username, self.tmp_user.username, 'rw') + FileParticipant.objects.add_by_file_path_and_username( + repo_id=self.repo.id, file_path=self.file, username=self.tmp_user.username) + + def tearDown(self): + self.remove_repo() + self.remove_user(self.tmp_user.email) + + def test_can_delete(self): + data = 'email=' + self.tmp_user.username + resp = self.client.delete(self.url, data, 'application/x-www-form-urlencoded') + + self.assertEqual(200, resp.status_code) + + json_resp = json.loads(resp.content) + assert json_resp['success'] + + def test_can_not_delete_by_not_exists_path(self): + invalid_path = randstring(5) + data = 'email=' + self.tmp_user.username + resp = self.client.delete(self.url + invalid_path, data, 'application/x-www-form-urlencoded') + + self.assertEqual(404, resp.status_code) + + def test_can_not_delete_by_invalid_user_permission(self): + self.logout() + self.login_as(self.admin) + + data = 'email=' + self.tmp_user.username + resp = self.client.delete(self.url, data, 'application/x-www-form-urlencoded') + + self.assertEqual(403, resp.status_code) diff --git a/tests/seahub/file_participant/test_file_participants.py b/tests/seahub/file_participant/test_file_participants.py new file mode 100644 index 0000000000..1dc40d9e10 --- /dev/null +++ b/tests/seahub/file_participant/test_file_participants.py @@ -0,0 +1,88 @@ +import json + +from seaserv import seafile_api +from django.core.urlresolvers import reverse +from seahub.file_participants.models import FileParticipant +from seahub.test_utils import BaseTestCase +from tests.common.utils import randstring + + +class FileParticipantsTest(BaseTestCase): + def setUp(self): + self.tmp_user = self.create_user() + + self.login_as(self.user) + self.url = reverse('api-v2.1-file-participants', args=[self.repo.id]) + '?path=' + self.file + # share repo and add participant + seafile_api.share_repo(self.repo.id, self.user.username, self.tmp_user.username, 'rw') + FileParticipant.objects.add_by_file_path_and_username( + repo_id=self.repo.id, file_path=self.file, username=self.user.username) + + def tearDown(self): + self.remove_repo() + self.remove_user(self.tmp_user.email) + + def test_can_list(self): + resp = self.client.get(self.url) + self.assertEqual(200, resp.status_code) + + json_resp = json.loads(resp.content) + assert json_resp['participant_list'] + assert json_resp['participant_list'][0] + assert json_resp['participant_list'][0]['email'] == self.user.username + assert json_resp['participant_list'][0]['avatar_url'] + assert json_resp['participant_list'][0]['contact_email'] + assert json_resp['participant_list'][0]['name'] + + def test_can_not_list_by_not_exists_path(self): + invalid_path = randstring(5) + resp = self.client.get(self.url + invalid_path) + self.assertEqual(404, resp.status_code) + + def test_can_not_list_by_invalid_user_permission(self): + self.logout() + self.login_as(self.admin) + + resp = self.client.get(self.url) + self.assertEqual(403, resp.status_code) + + def test_can_post(self): + resp = self.client.post(self.url, { + 'email': self.tmp_user.username + }) + self.assertEqual(201, resp.status_code) + + json_resp = json.loads(resp.content) + assert json_resp['email'] == self.tmp_user.username + assert json_resp['avatar_url'] + assert json_resp['contact_email'] + assert json_resp['name'] + + def test_can_not_post_by_not_exists_path(self): + invalid_path = randstring(5) + resp = self.client.post(self.url + invalid_path, { + 'email': self.tmp_user.username + }) + self.assertEqual(404, resp.status_code) + + def test_can_not_post_by_invalid_user_permission(self): + self.logout() + self.login_as(self.admin) + + resp = self.client.post(self.url, { + 'email': self.tmp_user.username + }) + self.assertEqual(403, resp.status_code) + + def test_can_not_post_by_not_exists_user(self): + invalid_email = randstring(5) + '@seafile.com' + resp = self.client.post(self.url, { + 'email': invalid_email + }) + self.assertEqual(404, resp.status_code) + + def test_can_not_post_by_invalid_email_permission(self): + resp = self.client.post(self.url, { + 'email': self.admin.username + }) + self.assertEqual(403, resp.status_code)