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 {
+
+
+
+
+
+ |
+
 |
{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'),