diff --git a/frontend/src/components/common/notice-item.js b/frontend/src/components/common/notice-item.js index b63343d4dc..366190fc95 100644 --- a/frontend/src/components/common/notice-item.js +++ b/frontend/src/components/common/notice-item.js @@ -19,6 +19,7 @@ const MSG_TYPE_FILE_COMMENT = 'file_comment'; const MSG_TYPE_DRAFT_COMMENT = 'draft_comment'; const MSG_TYPE_DRAFT_REVIEWER = 'draft_reviewer'; const MSG_TYPE_GUEST_INVITATION_ACCEPTED = 'guest_invitation_accepted'; +const MSG_TYPE_REPO_MONITOR = 'repo_monitor'; class NoticeItem extends React.Component { @@ -70,7 +71,7 @@ class NoticeItem extends React.Component { notice = notice.replace('{share_from}', shareFrom); notice = notice.replace('{repo_link}', `{tagA}${repoName}{/tagA}`); notice = Utils.HTMLescape(notice); - + // 3. add jump link notice = notice.replace('{tagA}', ``); notice = notice.replace('{/tagA}', ''); @@ -98,13 +99,13 @@ class NoticeItem extends React.Component { } else { notice = gettext('{share_from} has shared a folder named {repo_link} to group {group_link}.'); } - + // 2. handle xss(cross-site scripting) notice = notice.replace('{share_from}', shareFrom); notice = notice.replace('{repo_link}', `{tagA}${repoName}{/tagA}`); notice = notice.replace('{group_link}', `{tagB}${groupName}{/tagB}`); notice = Utils.HTMLescape(notice); - + // 3. add jump link notice = notice.replace('{tagA}', ``); notice = notice.replace('{/tagA}', ''); @@ -128,7 +129,7 @@ class NoticeItem extends React.Component { notice = notice.replace('{user}', repoOwner); notice = notice.replace('{repo_link}', `{tagA}${repoName}{/tagA}`); notice = Utils.HTMLescape(notice); - + // 3. add jump link notice = notice.replace('{tagA}', ``); notice = notice.replace('{/tagA}', ''); @@ -151,7 +152,7 @@ class NoticeItem extends React.Component { notice = notice.replace('{upload_file_link}', `{tagA}${fileName}{/tagA}`); notice = notice.replace('{uploaded_link}', `{tagB}${folderName}{/tagB}`); notice = Utils.HTMLescape(notice); - + // 3. add jump link notice = notice.replace('{tagA}', ``); notice = notice.replace('{/tagA}', ''); @@ -180,12 +181,12 @@ class NoticeItem extends React.Component { // 1. handle translate let notice = gettext('File {file_link} has a new comment form user {author}.'); - + // 2. handle xss(cross-site scripting) notice = notice.replace('{file_link}', `{tagA}${fileName}{/tagA}`); notice = notice.replace('{author}', author); notice = Utils.HTMLescape(notice); - + // 3. add jump link notice = notice.replace('{tagA}', ``); notice = notice.replace('{/tagA}', ''); @@ -224,6 +225,72 @@ class NoticeItem extends React.Component { return {avatar_url, notice}; } + if (noticeType === MSG_TYPE_REPO_MONITOR) { + let avatar_url = detail.op_user_avatar_url; + let repoLink = siteRoot + 'library/' + detail.repo_id + '/' + detail.repo_name + '/'; + let notice = ''; + + let op = ''; + if (detail.obj_type == 'file') { + switch (detail.op_type) { + case 'create': + op = gettext('created file'); + break; + case 'delete': + op = gettext('deleted file'); + break; + case 'recover': + op = gettext('restored file'); + break; + case 'rename': + op = gettext('renamed file'); + break; + case 'move': + op = gettext('moved file'); + break; + case 'edit': + op = gettext('updated file'); + break; + } + } else { // dir + switch (detail.op_type) { + case 'create': + op = gettext('created folder'); + break; + case 'delete': + op = gettext('deleted folder'); + break; + case 'recover': + op = gettext('restored folder'); + break; + case 'rename': + op = gettext('renamed folder'); + break; + case 'move': + op = gettext('moved folder'); + break; + } + } + + // 1. handle translate + notice = gettext('{op_user} {op_type} {obj_name} in {repo_link}.'); + + let obj_name = Utils.getFileName(detail.obj_path_list[0]); + + // 2. handle xss(cross-site scripting) + notice = notice.replace('{op_user}', `${detail.op_user_name}`); + notice = notice.replace('{op_type}', `${op}`); + notice = notice.replace('{obj_name}', `${obj_name}`); + notice = notice.replace('{repo_link}', `{tagA}${detail.repo_name}{/tagA}`); + notice = Utils.HTMLescape(notice); + + // 3. add jump link + notice = notice.replace('{tagA}', ``); + notice = notice.replace('{/tagA}', ''); + + return {avatar_url, notice}; + } + // if (noticeType === MSG_TYPE_GUEST_INVITATION_ACCEPTED) { // } diff --git a/frontend/src/models/repo.js b/frontend/src/models/repo.js index eba38eaffc..744c36f1cb 100644 --- a/frontend/src/models/repo.js +++ b/frontend/src/models/repo.js @@ -17,6 +17,7 @@ class Repo { this.modifier_name = object.modifier_name; this.type = object.type; this.starred = object.starred; + this.monitored = object.monitored; this.status = object.status; this.storage_name = object.storage_name; if (object.is_admin != undefined) { diff --git a/frontend/src/pages/my-libs/mylib-repo-list-item.js b/frontend/src/pages/my-libs/mylib-repo-list-item.js index 38323a7c4a..8ad02d13c4 100644 --- a/frontend/src/pages/my-libs/mylib-repo-list-item.js +++ b/frontend/src/pages/my-libs/mylib-repo-list-item.js @@ -40,6 +40,7 @@ class MylibRepoListItem extends React.Component { this.state = { isOpIconShow: false, isStarred: this.props.repo.starred, + isMonitored: this.props.repo.monitored, isRenaming: false, isShareDialogShow: false, isDeleteDialogShow: false, @@ -165,6 +166,32 @@ class MylibRepoListItem extends React.Component { } } + onToggleMonitorRepo = (e) => { + e.preventDefault(); + const repoName = this.props.repo.repo_name; + if (this.state.isMonitored) { + seafileAPI.unMonitorRepo(this.props.repo.repo_id).then(() => { + this.setState({isMonitored: !this.state.isMonitored}); + const msg = gettext('Successfully unmonitored {library_name_placeholder}.') + .replace('{library_name_placeholder}', repoName); + toaster.success(msg); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } else { + seafileAPI.monitorRepo(this.props.repo.repo_id).then(() => { + this.setState({isMonitored: !this.state.isMonitored}); + const msg = gettext('Successfully monitored {library_name_placeholder}.') + .replace('{library_name_placeholder}', repoName); + toaster.success(msg); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + } + onShareToggle = (e) => { // when close share dialog after send share link email, // there is no event @@ -297,6 +324,13 @@ class MylibRepoListItem extends React.Component { + + + + + + + {iconTitle} {this.state.isRenaming && ( diff --git a/frontend/src/pages/my-libs/mylib-repo-list-view.js b/frontend/src/pages/my-libs/mylib-repo-list-view.js index 3f6238f516..98a043c3cf 100644 --- a/frontend/src/pages/my-libs/mylib-repo-list-view.js +++ b/frontend/src/pages/my-libs/mylib-repo-list-view.js @@ -85,8 +85,9 @@ class MylibRepoListView extends React.Component { {gettext('Library Type')} + {gettext('Library Type')} {gettext('Name')} {this.props.sortBy === 'name' && sortIcon} - {gettext('Actions')} + {gettext('Actions')} {gettext('Size')} {this.props.sortBy === 'size' && sortIcon} {showStorageBackend ? {gettext('Storage Backend')} : null} {gettext('Last Update')} {this.props.sortBy === 'time' && sortIcon} diff --git a/seahub/api2/endpoints/monitored_repos.py b/seahub/api2/endpoints/monitored_repos.py new file mode 100644 index 0000000000..ab0de00c1c --- /dev/null +++ b/seahub/api2/endpoints/monitored_repos.py @@ -0,0 +1,111 @@ +# Copyright (c) 2012-2018 Seafile Ltd. +import logging + +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 rest_framework import status + +from seaserv import seafile_api + +from seahub.api2.utils import api_error +from seahub.api2.authentication import TokenAuthentication +from seahub.api2.throttling import UserRateThrottle +from seahub.utils.repo import is_repo_owner + +from seahub.base.models import UserMonitoredRepos +from seahub.base.templatetags.seahub_tags import email2nickname, email2contact_email + +logger = logging.getLogger(__name__) + + +class MonitoredRepos(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def post(self, request): + """ Monitor a repo. + + Permission checking: + 1. Only repo owner can perform this action. + """ + + # argument check + repo_id = request.data.get('repo_id', None) + if not repo_id: + error_msg = 'repo_id invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # resource check + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # permission check + email = request.user.username + if not is_repo_owner(request, repo_id, email): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # monitor a repo + monitored_repos = UserMonitoredRepos.objects.filter(email=email, repo_id=repo_id) + if monitored_repos: + error_msg = 'Library {} has been monitored.'.format(repo_id) + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + try: + monitored_repo = UserMonitoredRepos.objects.create(email=email, + repo_id=repo_id) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + # get info of new monitored repo + item_info = {} + item_info['user_email'] = email + item_info['user_name'] = email2nickname(email) + item_info['user_contact_email'] = email2contact_email(email) + item_info['repo_id'] = monitored_repo.repo_id + + return Response(item_info) + + +class MonitoredRepo(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def delete(self, request, repo_id): + """ Unmonitored repo. + + Permission checking: + 1. Only repo owner can perform this action. + """ + + # resource check + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # permission check + email = request.user.username + if not is_repo_owner(request, repo_id, email): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # unmonitor repo + try: + UserMonitoredRepos.objects.filter(email=email, repo_id=repo_id).delete() + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'success': True}) diff --git a/seahub/api2/endpoints/notifications.py b/seahub/api2/endpoints/notifications.py index 17f7ffc33d..2d25d845d5 100644 --- a/seahub/api2/endpoints/notifications.py +++ b/seahub/api2/endpoints/notifications.py @@ -14,9 +14,8 @@ from seahub.api2.throttling import UserRateThrottle from seahub.notifications.models import UserNotification from seahub.notifications.models import get_cache_key_of_unseen_notifications -from seahub.notifications.views import add_notice_from_info from seahub.notifications.utils import update_notice_detail -from seahub.api2.utils import api_error, to_python_boolean +from seahub.api2.utils import api_error from seahub.utils.timeutils import datetime_to_isoformat_timestr logger = logging.getLogger(__name__) @@ -117,7 +116,6 @@ class NotificationsView(APIView): return Response({'success': True}) - class NotificationView(APIView): authentication_classes = (TokenAuthentication, SessionAuthentication) diff --git a/seahub/api2/endpoints/repos.py b/seahub/api2/endpoints/repos.py index a4227b05b2..708cc85e1f 100644 --- a/seahub/api2/endpoints/repos.py +++ b/seahub/api2/endpoints/repos.py @@ -14,7 +14,7 @@ from seahub.api2.utils import api_error from seahub.api2.endpoints.group_owned_libraries import get_group_id_by_repo_owner -from seahub.base.models import UserStarredFiles +from seahub.base.models import UserStarredFiles, UserMonitoredRepos from seahub.base.templatetags.seahub_tags import email2nickname, \ email2contact_email from seahub.signals import repo_deleted @@ -79,6 +79,13 @@ class ReposView(APIView): logger.error(e) starred_repo_id_list = [] + try: + monitored_repos = UserMonitoredRepos.objects.filter(email=email) + monitored_repo_id_list = [item.repo_id for item in monitored_repos] + except Exception as e: + logger.error(e) + monitored_repo_id_list = [] + repo_info_list = [] if filter_by['mine']: @@ -120,6 +127,7 @@ class ReposView(APIView): "encrypted": r.encrypted, "permission": 'rw', # Always have read-write permission to owned repo "starred": r.repo_id in starred_repo_id_list, + "monitored": r.repo_id in monitored_repo_id_list, "status": normalize_repo_status_code(r.status), "salt": r.salt if r.enc_version >= 3 else '', } diff --git a/seahub/base/models.py b/seahub/base/models.py index 068d87bd1c..a9f0b3b10d 100644 --- a/seahub/base/models.py +++ b/seahub/base/models.py @@ -1,6 +1,5 @@ # Copyright (c) 2012-2016 Seafile Ltd. import os -import datetime import logging from django.db import models from django.db.models import Q @@ -10,7 +9,7 @@ from pysearpc import SearpcError from seaserv import seafile_api from seahub.auth.signals import user_logged_in -from seahub.utils import calc_file_path_hash, within_time_range, \ +from seahub.utils import within_time_range, \ normalize_file_path, normalize_dir_path from seahub.utils.timeutils import datetime_to_isoformat_timestr from seahub.tags.models import FileUUIDMap @@ -37,6 +36,7 @@ class TimestampedModel(models.Model): # default ordering for most models. ordering = ['-created_at', '-updated_at'] + class FileCommentManager(models.Manager): def add(self, repo_id, parent_path, item_name, author, comment, detail=''): fileuuidmap = FileUUIDMap.objects.get_or_create_fileuuidmap(repo_id, @@ -67,7 +67,7 @@ class FileCommentManager(models.Manager): def get_by_parent_path(self, repo_id, parent_path): uuids = FileUUIDMap.objects.get_fileuuidmaps_by_parent_path(repo_id, - parent_path) + parent_path) objs = super(FileCommentManager, self).filter(uuid__in=uuids) return objs @@ -104,7 +104,7 @@ class FileComment(models.Model): } -########## starred files +# starred files class StarredFile(object): def format_path(self): if self.path == "/": @@ -129,6 +129,7 @@ class StarredFile(object): if not is_dir: self.name = path.split('/')[-1] + class UserStarredFilesManager(models.Manager): def get_starred_repos_by_user(self, email): @@ -139,23 +140,26 @@ class UserStarredFilesManager(models.Manager): def get_starred_item(self, email, repo_id, path): path_list = [normalize_file_path(path), normalize_dir_path(path)] - starred_items = UserStarredFiles.objects.filter(email=email, - repo_id=repo_id).filter(Q(path__in=path_list)) + starred_items = UserStarredFiles.objects.filter(email=email, repo_id=repo_id) \ + .filter(Q(path__in=path_list)) return starred_items[0] if len(starred_items) > 0 else None def add_starred_item(self, email, repo_id, path, is_dir, org_id=-1): starred_item = UserStarredFiles.objects.create(email=email, - repo_id=repo_id, path=path, is_dir=is_dir, org_id=org_id) + repo_id=repo_id, + path=path, + is_dir=is_dir, + org_id=org_id) return starred_item def delete_starred_item(self, email, repo_id, path): path_list = [normalize_file_path(path), normalize_dir_path(path)] - starred_items = UserStarredFiles.objects.filter(email=email, - repo_id=repo_id).filter(Q(path__in=path_list)) + starred_items = UserStarredFiles.objects.filter(email=email, repo_id=repo_id) \ + .filter(Q(path__in=path_list)) for item in starred_items: item.delete() @@ -201,8 +205,9 @@ class UserStarredFilesManager(models.Manager): sfile.delete() continue + # TODO: remove ``size`` from StarredFile f = StarredFile(sfile.org_id, repo, file_id, sfile.path, - sfile.is_dir, 0) # TODO: remove ``size`` from StarredFile + sfile.is_dir, 0) ret.append(f) '''Calculate files last modification time''' @@ -227,6 +232,7 @@ class UserStarredFilesManager(models.Manager): return ret + class UserStarredFiles(models.Model): """Starred files are marked by users to get quick access to it on user home page. @@ -240,7 +246,8 @@ class UserStarredFiles(models.Model): objects = UserStarredFilesManager() -########## misc + +# misc class UserLastLoginManager(models.Manager): def get_by_username(self, username): """Return last login record for a user, delete duplicates if there are @@ -258,11 +265,13 @@ class UserLastLoginManager(models.Manager): logger.warn('Delete duplicate user last login record: %s' % username) return ret + class UserLastLogin(models.Model): username = models.CharField(max_length=255, db_index=True) last_login = models.DateTimeField(default=timezone.now) objects = UserLastLoginManager() + def update_last_login(sender, user, **kwargs): """ A signal receiver which updates the last_login date for @@ -273,14 +282,18 @@ def update_last_login(sender, user, **kwargs): user_last_login = UserLastLogin(username=user.username) user_last_login.last_login = timezone.now() user_last_login.save() + + user_logged_in.connect(update_last_login) + class CommandsLastCheck(models.Model): """Record last check time for Django/custom commands. """ command_type = models.CharField(max_length=100) last_check = models.DateTimeField() + class DeviceToken(models.Model): """ The iOS device token model. @@ -297,8 +310,10 @@ class DeviceToken(models.Model): def __unicode__(self): return "/".join(self.user, self.token) + _CLIENT_LOGIN_TOKEN_EXPIRATION_SECONDS = 30 + class ClientLoginTokenManager(models.Manager): def get_username(self, tokenstr): try: @@ -312,6 +327,7 @@ class ClientLoginTokenManager(models.Manager): return None return username + class ClientLoginToken(models.Model): # TODO: update sql/mysql.sql and sql/sqlite3.sql token = models.CharField(max_length=32, primary_key=True) @@ -349,3 +365,11 @@ class RepoSecretKey(models.Model): secret_key = models.CharField(max_length=44) objects = RepoSecretKeyManager() + + +class UserMonitoredRepos(models.Model): + """ + """ + email = models.EmailField(db_index=True) + repo_id = models.CharField(max_length=36, db_index=True) + timestamp = models.DateTimeField(default=timezone.now) diff --git a/seahub/notifications/models.py b/seahub/notifications/models.py index 09c3068966..5cc0f826ce 100644 --- a/seahub/notifications/models.py +++ b/seahub/notifications/models.py @@ -74,6 +74,7 @@ MSG_TYPE_DRAFT_COMMENT = 'draft_comment' MSG_TYPE_DRAFT_REVIEWER = 'draft_reviewer' MSG_TYPE_GUEST_INVITATION_ACCEPTED = 'guest_invitation_accepted' MSG_TYPE_REPO_TRANSFER = 'repo_transfer' +MSG_TYPE_REPO_MINOTOR = 'repo_monitor' USER_NOTIFICATION_COUNT_CACHE_PREFIX = 'USER_NOTIFICATION_COUNT_' @@ -403,6 +404,9 @@ class UserNotification(models.Model): def is_repo_transfer_msg(self): return self.msg_type == MSG_TYPE_REPO_TRANSFER + def is_repo_monitor_msg(self): + return self.msg_type == MSG_TYPE_REPO_MINOTOR + def user_message_detail_to_dict(self): """Parse user message detail, returns dict contains ``message`` and ``msg_from``. diff --git a/seahub/notifications/utils.py b/seahub/notifications/utils.py index 88884faba6..b46deeff1a 100644 --- a/seahub/notifications/utils.py +++ b/seahub/notifications/utils.py @@ -3,7 +3,6 @@ import os import json import logging from django.core.cache import cache -from django.urls import reverse from seaserv import ccnet_api, seafile_api from seahub.notifications.models import Notification @@ -227,4 +226,25 @@ def update_notice_detail(request, notices): except Exception as e: logger.error(e) + elif notice.is_repo_monitor_msg(): + try: + d = json.loads(notice.detail) + + repo_id = d['repo_id'] + if repo_id in repo_dict: + repo = repo_dict[repo_id] + else: + repo = seafile_api.get_repo(repo_id) + repo_dict[repo_id] = repo + + op_user_email = d.pop('op_user') + url, is_default, date_uploaded = api_avatar_url(op_user_email, 32) + d['op_user_avatar_url'] = url + d['op_user_email'] = op_user_email + d['op_user_name'] = email2nickname(op_user_email) + d['op_user_contact_email'] = email2contact_email(op_user_email) + notice.detail = d + except Exception as e: + logger.error(e) + return notices diff --git a/seahub/urls.py b/seahub/urls.py index 0585e46736..d68243ffaa 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -94,6 +94,7 @@ from seahub.api2.endpoints.tag_filter_file import TaggedFilesView from seahub.api2.endpoints.related_files import RelatedFilesView, RelatedFileView from seahub.api2.endpoints.webdav_secret import WebdavSecretView from seahub.api2.endpoints.starred_items import StarredItems +from seahub.api2.endpoints.monitored_repos import MonitoredRepos, MonitoredRepo from seahub.api2.endpoints.markdown_lint import MarkdownLintView from seahub.api2.endpoints.public_repos_search import PublishedRepoSearchView from seahub.api2.endpoints.recent_added_files import RecentAddedFilesView @@ -467,6 +468,10 @@ urlpatterns = [ ## user::starred-item url(r'^api/v2.1/starred-items/$', StarredItems.as_view(), name='api-v2.1-starred-items'), + ## user::monitored-repos + url(r'^api/v2.1/monitored-repos/$', MonitoredRepos.as_view(), name='api-v2.1-monitored-repos'), + url(r'^api/v2.1/monitored-repos/(?P[-0-9a-f]{36})/$', MonitoredRepo.as_view(), name='api-v2.1-monitored-repo'), + ## user::wiki url(r'^api/v2.1/wikis/$', WikisView.as_view(), name='api-v2.1-wikis'), url(r'^api/v2.1/wikis/(?P[^/]+)/$', WikiView.as_view(), name='api-v2.1-wiki'),