diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 461ab55a49..6b8d1f9303 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -98,6 +98,30 @@ "react-popper": "^0.8.3", "react-transition-group": "^2.2.1" } + }, + "unified": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/unified/-/unified-6.2.0.tgz", + "integrity": "sha512-1k+KPhlVtqmG99RaTbAv/usu85fcSRu3wY8X+vnsEhIxNP5VbVIDiXnLqyKIG+UMdyTg0ZX9EI6k2AfjJkHPtA==", + "requires": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^1.1.0", + "trough": "^1.0.0", + "vfile": "^2.0.0", + "x-is-string": "^0.1.0" + } + }, + "vfile": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-2.3.0.tgz", + "integrity": "sha512-ASt4mBUHcTpMKD/l5Q+WJXNtshlWxOogYyGYYrg4lt/vuRjC1EFQtlAofL5VmtVNIZJzWYFJjzGWZ0Gw8pzW1w==", + "requires": { + "is-buffer": "^1.1.4", + "replace-ext": "1.0.0", + "unist-util-stringify-position": "^1.0.0", + "vfile-message": "^1.0.0" + } } } }, @@ -1896,7 +1920,7 @@ }, "browserify-rsa": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", + "resolved": "http://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", "dev": true, "requires": { @@ -1947,7 +1971,7 @@ }, "buffer": { "version": "4.9.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "resolved": "http://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", "dev": true, "requires": { @@ -3838,7 +3862,7 @@ }, "events": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "resolved": "http://registry.npmjs.org/events/-/events-1.1.1.tgz", "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=", "dev": true }, @@ -9775,6 +9799,32 @@ "remark-parse": "^5.0.0", "remark-stringify": "^5.0.0", "unified": "^6.0.0" + }, + "dependencies": { + "unified": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/unified/-/unified-6.2.0.tgz", + "integrity": "sha512-1k+KPhlVtqmG99RaTbAv/usu85fcSRu3wY8X+vnsEhIxNP5VbVIDiXnLqyKIG+UMdyTg0ZX9EI6k2AfjJkHPtA==", + "requires": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^1.1.0", + "trough": "^1.0.0", + "vfile": "^2.0.0", + "x-is-string": "^0.1.0" + } + }, + "vfile": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-2.3.0.tgz", + "integrity": "sha512-ASt4mBUHcTpMKD/l5Q+WJXNtshlWxOogYyGYYrg4lt/vuRjC1EFQtlAofL5VmtVNIZJzWYFJjzGWZ0Gw8pzW1w==", + "requires": { + "is-buffer": "^1.1.4", + "replace-ext": "1.0.0", + "unist-util-stringify-position": "^1.0.0", + "vfile-message": "^1.0.0" + } + } } }, "remark-breaks": { @@ -10175,9 +10225,9 @@ } }, "seafile-js": { - "version": "0.2.26", - "resolved": "https://registry.npmjs.org/seafile-js/-/seafile-js-0.2.26.tgz", - "integrity": "sha512-iKP2nLBCDE2G4MoiVdtQ4JlKY3vUByb7dwbwOV/pC8OdSxYcpu1zmCFl45TLnhz8B6wOlc7EC00ByZAvyTaZOQ==", + "version": "0.2.28", + "resolved": "https://registry.npmjs.org/seafile-js/-/seafile-js-0.2.28.tgz", + "integrity": "sha512-+QA16BLpNVrvYIHfvrU/0D0x90bb1gCzW31U2BeFdL1CT3vgdLXaVc+j0XAtDahBYTyKtu7YKGPR6UO0ZdlXdw==", "requires": { "axios": "^0.18.0", "form-data": "^2.3.2" @@ -11183,7 +11233,7 @@ }, "yargs": { "version": "3.10.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "resolved": "http://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", "dev": true, "requires": { @@ -11210,15 +11260,15 @@ } }, "unified": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/unified/-/unified-6.2.0.tgz", - "integrity": "sha512-1k+KPhlVtqmG99RaTbAv/usu85fcSRu3wY8X+vnsEhIxNP5VbVIDiXnLqyKIG+UMdyTg0ZX9EI6k2AfjJkHPtA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/unified/-/unified-7.0.0.tgz", + "integrity": "sha512-j+Sm7upmmt3RXPBeA+KFGYBlHBxClnby2DtxezFKwMfhWTAklY4WbEdhwRo6c6GpuHdi04YDsyPKY/kh5a/xnQ==", "requires": { "bail": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^1.1.0", "trough": "^1.0.0", - "vfile": "^2.0.0", + "vfile": "^3.0.0", "x-is-string": "^0.1.0" } }, @@ -11499,14 +11549,21 @@ } }, "vfile": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-2.3.0.tgz", - "integrity": "sha512-ASt4mBUHcTpMKD/l5Q+WJXNtshlWxOogYyGYYrg4lt/vuRjC1EFQtlAofL5VmtVNIZJzWYFJjzGWZ0Gw8pzW1w==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-3.0.0.tgz", + "integrity": "sha512-X2DiPHL9Nxgfyu5DNVgtTkZtD4d4Zzf7rVBVI+uXP2pWWIQG8Ri+xAP9KdH/sB6SS0a1niWp5bRF88n4ciwhoA==", "requires": { - "is-buffer": "^1.1.4", + "is-buffer": "^2.0.0", "replace-ext": "1.0.0", "unist-util-stringify-position": "^1.0.0", "vfile-message": "^1.0.0" + }, + "dependencies": { + "is-buffer": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", + "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==" + } } }, "vfile-location": { @@ -11671,7 +11728,7 @@ }, "load-json-file": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", "dev": true, "requires": { @@ -11794,13 +11851,13 @@ "dependencies": { "ansi-regex": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-1.1.1.tgz", + "resolved": "http://registry.npmjs.org/ansi-regex/-/ansi-regex-1.1.1.tgz", "integrity": "sha1-QchHGUZGN15qGl0Qw8oFTvn8mA0=", "dev": true }, "strip-ansi": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-2.0.1.tgz", "integrity": "sha1-32LBqpTtLxFOHQ8h/R1QSCt5pg4=", "dev": true, "requires": { @@ -11961,7 +12018,7 @@ }, "yargs": { "version": "6.6.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-6.6.0.tgz", + "resolved": "http://registry.npmjs.org/yargs/-/yargs-6.6.0.tgz", "integrity": "sha1-eC7CHvQDNF+DCoCMo9UTr1YGUgg=", "dev": true, "requires": { diff --git a/frontend/package.json b/frontend/package.json index 8f40192e35..6402212fa6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,11 +25,13 @@ "react-dom": "^16.5.2", "react-moment": "^0.7.9", "reactstrap": "^6.4.0", - "seafile-js": "^0.2.26", + "seafile-js": "^0.2.28", "seafile-ui": "^0.1.10", "sw-precache-webpack-plugin": "0.11.4", + "unified": "^7.0.0", "url-loader": "0.6.2", "url-parse": "^1.4.3", + "vfile": "^3.0.0", "whatwg-fetch": "2.0.3" }, "scripts": { diff --git a/frontend/src/components/review-list-view/review-comments.js b/frontend/src/components/review-list-view/review-comments.js new file mode 100644 index 0000000000..ed8ad74a7b --- /dev/null +++ b/frontend/src/components/review-list-view/review-comments.js @@ -0,0 +1,198 @@ +import React from 'react'; +import { processor } from "../../utils/seafile-markdown2html"; +import { Button, Input, Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap'; +import { seafileAPI } from '../../utils/seafile-api'; +import { draftID, reviewID, gettext } from '../../utils/constants'; +import moment from 'moment'; +import Loading from '../../components/loading.js'; + +import '../../css/review-comments.css'; + +class ReviewComments extends React.Component { + + constructor(props) { + super(props); + this.state = { + commentsList: [], + userAvatar: `${window.location.host}media/avatars/default.png`, + } + this.accountInfo = {}; + } + + listComments = () => { + seafileAPI.listReviewComments(reviewID).then((response) => { + this.setState({ + commentsList: response.data.comments + }); + }); + } + + getUserAvatar = () => { + seafileAPI.getAccountInfo().then((res) => { + this.accountInfo = res.data; + this.setState({ + userAvatar: res.data.avatar_url, + }) + }) + } + + handleCommentChange = (event) => { + this.setState({ + comment: event.target.value, + }); + } + + submitComment = () => { + let comment = this.refs.commentTextarea.value; + if (comment.trim().length > 0) { + seafileAPI.addReviewComment(reviewID, comment.trim()).then((res) => { + this.listComments(); + this.props.getCommentsNumber(); + }); + this.refs.commentTextarea.value = ''; + } + } + + resolveComment = (event) => { + seafileAPI.updateReviewComment(reviewID, event.target.id, 'true').then((res) => { + this.listComments(); + }); + } + + deleteComment = (event) => { + seafileAPI.deleteReviewComment(reviewID, event.target.id).then((res) => { + this.props.getCommentsNumber(); + this.listComments(); + }); + } + + componentWillMount() { + this.getUserAvatar(); + this.listComments(); + } + + render() { + return ( +
+
+
+ +
+
{gettext('Comments')}
+
+ { this.props.commentsNumber == 0 && +
+
{gettext('No comment yet.')}
+
+ } + { (this.state.commentsList.length == 0 && this.props.commentsNumber > 0) && +
+ } + +
+
+ +
+
+ + +
+
+
+ ) + } +} + +class CommentItem extends React.Component { + + constructor(props) { + super(props); + this.state = { + dropdownOpen: false, + html: '', + } + } + + toggleDropDownMenu = () => { + this.setState({ + dropdownOpen: !this.state.dropdownOpen, + }) + } + + convertComment = (mdFile) => { + processor.process(mdFile).then( + (result) => { + let html = String(result); + this.setState({ + html: html + }) + } + ); + } + + componentWillMount() { + this.convertComment(this.props.comment); + } + + componentWillReceiveProps(nextProps) { + this.convertComment(nextProps.comment); + } + + render() { + return ( +
  • +
    + +
    +
    {this.props.name}
    +
    {this.props.time}
    +
    + + + + + + { + (this.props.user_email === this.props.accountInfo.email) && + {gettext('Delete')} + } + {gettext('Mark as resolved')} + + +
    +
    +
  • + ) + } +} + +export default ReviewComments; diff --git a/frontend/src/css/draft-review.css b/frontend/src/css/draft-review.css new file mode 100644 index 0000000000..0c622c034a --- /dev/null +++ b/frontend/src/css/draft-review.css @@ -0,0 +1,35 @@ +.header .button-group { + display: flex; +} + +.header .common-list-btn { + margin-right: .25em; +} + +.header .common-list-btn .common-list-btn-number { + margin-left: .5em; +} + +.main .cur-view-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + box-sizing: border-box; + background-color: #fafaf9; +} + +.main .cur-view-container .cur-view-content { + flex: auto; +} + +.main .cur-view-container .cur-view-content-commenton { + overflow: auto; + margin-right: 30em; +} + +.main .cur-view-container .seafile-comment { + position: absolute; + right: 0; + height: calc(100% - 4.5em); +} \ No newline at end of file diff --git a/frontend/src/css/review-comments.css b/frontend/src/css/review-comments.css new file mode 100644 index 0000000000..d90f0c2b7d --- /dev/null +++ b/frontend/src/css/review-comments.css @@ -0,0 +1,123 @@ +.seafile-comment { + border-left: 1px solid #e6e6dd; + background-color: #fff; + display: flex; + flex-direction: column; + flex: 0 0 auto; + min-height: 18.5em; + width: 30em; +} +.seafile-comment-title { + border-bottom: 1px solid #e5e5e5; + min-height: 3em; + line-height: 3em; + padding: 0 1em; + display: flex; + flex-direction: row; + justify-content: space-between; + background-color: #fafaf9; +} +.seafile-comment-title .seafile-comment-title-text { + width: 100%; + text-align: center; + font-weight: 700; +} +.seafile-comment-title .seafile-comment-title-close { + color: #b9b9b9; +} +.seafile-comment-title .seafile-comment-title-close:hover { + color: #888; +} +.seafile-comment-list { + height: calc(100% - 40px); + overflow-y: auto; + margin-bottom: 0; +} +.seafile-comment .loading-icon { + margin-top: 20px; +} +.seafile-comment-list .comment-vacant { + padding: 1em; + text-align: center; +} +.seafile-comment-item { + overflow-y: auto; + padding: 15px 10px; + overflow-y: hidden; +} +.seafile-comment-item .seafile-comment-info { + padding-bottom: 0.5em; + height: 3em; + display: flex; + justify-content: flex-start; +} +.seafile-comment-item .seafile-comment-info .reviewer-head { + margin-top: .3em; +} +.seafile-comment-item .seafile-comment-info .reviewer-info { + padding-left: 10px; +} +.seafile-comment-item .seafile-comment-info .review-time { + font-size: .6em; + color: #777; +} +.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown { + margin-left: auto; +} +.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown button, +.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown button:hover, +.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown button:focus + { + border: none; + box-shadow: none; + background-color: #fff; + height: 100%; +} +.seafile-comment-item .seafile-comment-info.seafile-comment-dropdown .seafile-comment-dropdown-btn { + color: #999; +} +.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown:hover .seafile-comment-dropdown-btn { + color: #555; +} +.seafile-comment-item .seafile-comment-content { + margin-left: 42px; +} +.seafile-comment-item .seafile-comment-content ol, +.seafile-comment-item .seafile-comment-content ul, +.seafile-comment-item .seafile-comment-content li { + margin-left: 10px; +} +.seafile-comment-item .seafile-comment-content table, +.seafile-comment-item .seafile-comment-content th, +.seafile-comment-item .seafile-comment-content td { + border: 1px solid #333; +} +.seafile-comment-footer { + background-color: #fafaf9; + padding: 10px; + border-top: 1px solid #e5e5e5; + display: flex; + justify-content: flex-start; + min-height: 150px; +} +.seafile-comment-footer .seafile-add-comment { + margin-left: 10px; + overflow: hidden; + width: 25em; + display: flex; + flex-direction: column; +} +.seafile-comment-footer .seafile-add-comment .add-comment-input { + box-sizing: border-box; + background-color: #fff; + border: 1px solid #e6e6dd; + padding: 5px; + width: 25em; + min-height: 90px; +} +.seafile-comment-footer .seafile-add-comment .submit-comment { + margin-top: 5px; + width: 60px; + height: 28px; + min-height: 28px; +} \ No newline at end of file diff --git a/frontend/src/draft-review.js b/frontend/src/draft-review.js index 39712742b9..91bfd8da69 100644 --- a/frontend/src/draft-review.js +++ b/frontend/src/draft-review.js @@ -3,12 +3,13 @@ import ReactDOM from 'react-dom'; /* eslint-disable */ import Prism from 'prismjs'; /* eslint-enable */ -import { siteRoot, gettext, reviewID, draftOriginFilePath, draftFilePath, draftOriginRepoID, draftFileName, opStatus, publishFileVersion, originFileVersion } from './utils/constants'; +import { siteRoot, gettext, reviewID, draftOriginFilePath, draftFilePath, draftOriginRepoID, draftFileName, opStatus, publishFileVersion, originFileVersion } from './utils/constants'; import { seafileAPI } from './utils/seafile-api'; import axios from 'axios'; import DiffViewer from '@seafile/seafile-editor/dist/viewer/diff-viewer'; import Loading from './components/loading'; import Toast from './components/toast'; +import ReviewComments from './components/review-list-view/review-comments'; import 'seafile-ui'; import './assets/css/fa-solid.css'; @@ -17,6 +18,7 @@ import './assets/css/fontawesome.css'; import './css/layout.css'; import './css/initial-style.css'; import './css/toolbar.css'; +import './css/draft-review.css'; require('@seafile/seafile-editor/dist/editor/code-hight-package'); @@ -28,6 +30,8 @@ class DraftReview extends React.Component { draftOriginContent: '', reviewStatus: opStatus, isLoading: true, + commentsNumber: null, + isShowComments: false, }; } @@ -82,6 +86,25 @@ class DraftReview extends React.Component { }); } + toggleCommentList = () => { + this.setState({ + isShowComments: !this.state.isShowComments + }); + } + + getCommentsNumber = () => { + seafileAPI.listReviewComments(reviewID).then((res) => { + let number = res.data.total_count; + this.setState({ + commentsNumber: number, + }); + }); + } + + componentWillMount() { + this.getCommentsNumber(); + } + render() { return(
    @@ -95,25 +118,35 @@ class DraftReview extends React.Component { {gettext('review')}
    - { - this.state.reviewStatus === 'open' && -
    - - -
    - } - { - this.state.reviewStatus === 'finished' && -
    {gettext('Finished')}
    - } - { - this.state.reviewStatus === 'closed' && -
    {gettext('Closed')}
    - } +
    + + { + this.state.reviewStatus === 'open' && +
    + + +
    + } + { + this.state.reviewStatus === 'finished' && +
    {gettext('Finished')}
    + } + { + this.state.reviewStatus === 'closed' && +
    {gettext('Closed')}
    + } +
    -
    +
    {this.state.isLoading ? : @@ -121,6 +154,13 @@ class DraftReview extends React.Component { }
    + { this.state.isShowComments && + + }
    @@ -131,4 +171,4 @@ class DraftReview extends React.Component { ReactDOM.render ( , document.getElementById('wrapper') -); +); \ No newline at end of file diff --git a/frontend/src/utils/seafile-markdown2html.js b/frontend/src/utils/seafile-markdown2html.js new file mode 100644 index 0000000000..665c5d40f3 --- /dev/null +++ b/frontend/src/utils/seafile-markdown2html.js @@ -0,0 +1,54 @@ +var unified = require('unified'); +var markdown = require('remark-parse'); +var slug = require('remark-slug'); +var breaks = require('remark-breaks'); +var remark2rehype = require('remark-rehype'); +var format = require('rehype-format'); +var raw = require('rehype-raw'); +var xtend = require('xtend'); +var toHTML = require('hast-util-to-html'); +var sanitize = require('hast-util-sanitize'); +var gh = require('hast-util-sanitize/lib/github'); +var deepmerge = require('deepmerge').default; + +function stringify(config) { + var settings = xtend(config, this.data('settings')); + var schema = deepmerge(gh, { + "attributes":{ + "input": [ + "type", + ], + "li": [ + "className" + ], + "code":[ + "className", + ], + }, + "tagNames": [ + "input", + "code" + ] + }); + this.Compiler = compiler; + + function compiler(tree) { + var hast = sanitize(tree, schema); + return toHTML(hast, settings); + } +} + +var processor = unified() + .use(markdown, {commonmark: true}) + .use(breaks) + .use(slug) + .use(remark2rehype, {allowDangerousHTML: true}) + .use(raw) + .use(format) + .use(stringify); + +var processorGetAST = unified() + .use(markdown, {commonmark: true}) + .use(slug) + +export { processor, processorGetAST }; diff --git a/seahub/api2/endpoints/review_comments.py b/seahub/api2/endpoints/review_comments.py new file mode 100644 index 0000000000..136c404931 --- /dev/null +++ b/seahub/api2/endpoints/review_comments.py @@ -0,0 +1,211 @@ +# Copyright (c) 2012-2016 Seafile Ltd. +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 django.core.urlresolvers import reverse + +from seahub.api2.authentication import TokenAuthentication +from seahub.api2.throttling import UserRateThrottle +from seahub.api2.utils import api_error, user_to_dict, to_python_boolean +from seahub.avatar.settings import AVATAR_DEFAULT_SIZE +from seahub.utils.repo import get_repo_owner +from seahub.api2.endpoints.utils import generate_links_header_for_paginator +from seahub.views import check_folder_permission +from seahub.drafts.models import DraftReview, ReviewComment +from seahub.drafts.signals import comment_review_successful + +logger = logging.getLogger(__name__) + + +class ReviewCommentsView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated, ) + throttle_classes = (UserRateThrottle, ) + + def get(self, request, review_id, format=None): + """List all comments of a review. + """ + # resource check + try: + r = DraftReview.objects.get(pk=review_id) + except DraftReview.DoesNotExist: + return api_error(status.HTTP_404_NOT_FOUND, + 'Review %s not found' % review_id) + + resolved = request.GET.get('resolved', None) + if resolved not in ('true', 'false', None): + error_msg = 'resolved invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # permission check + if check_folder_permission(request, r.origin_repo_id, '/') is None: + return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.') + + try: + avatar_size = int(request.GET.get('avatar_size', + AVATAR_DEFAULT_SIZE)) + page = int(request.GET.get('page', '1')) + per_page = int(request.GET.get('per_page', '25')) + except ValueError: + avatar_size = AVATAR_DEFAULT_SIZE + page = 1 + per_page = 25 + + start = (page - 1) * per_page + end = page * per_page + + total_count = ReviewComment.objects.filter(review_id=r).count() + comments = [] + + if resolved is None: + file_comments = ReviewComment.objects.filter(review_id=r) + else: + comment_resolved = to_python_boolean(resolved) + file_comments = ReviewComment.objects.filter(review_id=r, resolved=comment_resolved)[start: end] + + for file_comment in file_comments: + comment = file_comment.to_dict() + comment.update(user_to_dict(file_comment.author, request=request, avatar_size=avatar_size)) + comments.append(comment) + + result = {'comments': comments, 'total_count': total_count} + resp = Response(result) + base_url = reverse('api2-review-comments', args=[review_id]) + links_header = generate_links_header_for_paginator(base_url, page, + per_page, total_count) + resp['Links'] = links_header + return resp + + def post(self, request, review_id, format=None): + """Post a comments of a review. + """ + + # resource check + try: + r = DraftReview.objects.get(pk=review_id) + except DraftReview.DoesNotExist: + return api_error(status.HTTP_404_NOT_FOUND, + 'Review %s not found' % review_id) + + # permission check + if check_folder_permission(request, r.origin_repo_id, '/') is None: + return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.') + + # argument check + comment = request.data.get('comment', '') + if not comment: + return api_error(status.HTTP_400_BAD_REQUEST, 'Comment can not be empty.') + + try: + avatar_size = int(request.GET.get('avatar_size', + AVATAR_DEFAULT_SIZE)) + except ValueError: + avatar_size = AVATAR_DEFAULT_SIZE + + detail = request.data.get('detail', '') + username = request.user.username + + review_comment = ReviewComment.objects.add(comment, detail, username, r) + + # Send notification to review creator + comment_review_successful.send(sender=None, review=r, comment=comment, author=username) + + comment = review_comment.to_dict() + comment.update(user_to_dict(username, request=request, avatar_size=avatar_size)) + return Response(comment) + + +class ReviewCommentView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated, ) + throttle_classes = (UserRateThrottle, ) + + def delete(self, request, review_id, comment_id, format=None): + """Delete a comment, only comment author or review creator can perform this op. + """ + # resource check + try: + r = DraftReview.objects.get(pk=review_id) + except DraftReview.DoesNotExist: + return api_error(status.HTTP_404_NOT_FOUND, + 'Review %s not found' % review_id) + + try: + review_comment = ReviewComment.objects.get(pk=comment_id) + except ReviewComment.DoesNotExist: + return api_error(status.HTTP_404_NOT_FOUND, + 'Review comment %s not found' % comment_id) + + username = request.user.username + if username != (review_comment.author and r.creator): + return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.') + + review_comment.delete() + + return Response(status.HTTP_200_OK) + + def put(self, request, review_id, comment_id, format=None): + """Update a comment, only comment author or review creator can perform + this op + 1.Change resolved of comment + 2.Add comment_detail + """ + + # argument check + resolved = request.data.get('resolved') + if resolved not in ('true', 'false', None): + error_msg = 'resolved invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + detail = request.data.get('detail') + + # resource check + try: + r = DraftReview.objects.get(pk=review_id) + except DraftReview.DoesNotExist: + return api_error(status.HTTP_404_NOT_FOUND, + 'Review %s not found' % review_id) + + try: + review_comment = ReviewComment.objects.get(pk=comment_id) + except ReviewComment.DoesNotExist: + error_msg = 'Review comment %s not found.' % comment_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # permission check + username = request.user.username + if username != (review_comment.author and r.creator): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + if resolved is not None: + comment_resolved = to_python_boolean(resolved) + try: + review_comment.resolved = comment_resolved + review_comment.save() + except Exception as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal error.') + + if detail is not None: + try: + review_comment.detail = detail + review_comment.save() + except Exception as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal error.') + + try: + avatar_size = int(request.GET.get('avatar_size', AVATAR_DEFAULT_SIZE)) + except ValueError: + avatar_size = AVATAR_DEFAULT_SIZE + + comment = review_comment.to_dict() + comment.update(user_to_dict(username, request=request, avatar_size=avatar_size)) + + return Response(comment) diff --git a/seahub/api2/urls.py b/seahub/api2/urls.py index 7e78913caa..6e1c95ec1a 100644 --- a/seahub/api2/urls.py +++ b/seahub/api2/urls.py @@ -17,6 +17,7 @@ from .endpoints.group_discussions import GroupDiscussions from .endpoints.group_discussion import GroupDiscussion from .endpoints.send_share_link_email import SendShareLinkView from .endpoints.send_upload_link_email import SendUploadLinkView +from .endpoints.review_comments import ReviewCommentsView, ReviewCommentView urlpatterns = [ url(r'^ping/$', Ping.as_view()), @@ -36,6 +37,8 @@ urlpatterns = [ url(r'^regdevice/$', RegDevice.as_view(), name="regdevice"), url(r'^search/$', Search.as_view(), name='api_search'), url(r'^search-user/$', SearchUser.as_view(), name='search-user'), + url(r'^review/(?P\d+)/comments/$', ReviewCommentsView.as_view(), name='api2-review-comments'), + url(r'^review/(?P\d+)/comment/(?P\d+)/$', ReviewCommentView.as_view(), name='api2-review-comment'), url(r'^repos/$', Repos.as_view(), name="api2-repos"), url(r'^repos/public/$', PubRepos.as_view(), name="api2-pub-repos"), url(r'^repos/(?P[-0-9a-f]{36})/$', Repo.as_view(), name="api2-repo"), diff --git a/seahub/drafts/migrations/0003_reviewcomment.py b/seahub/drafts/migrations/0003_reviewcomment.py new file mode 100644 index 0000000000..ce2b9939c4 --- /dev/null +++ b/seahub/drafts/migrations/0003_reviewcomment.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-10-16 07:04 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import seahub.base.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('drafts', '0002_auto_20181011_1207'), + ] + + operations = [ + migrations.CreateModel( + name='ReviewComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('author', seahub.base.fields.LowerCaseCharField(db_index=True, max_length=255)), + ('resolved', models.BooleanField(db_index=True, default=False)), + ('comment', models.TextField()), + ('detail', models.TextField()), + ('review_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='drafts.DraftReview')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + ] diff --git a/seahub/drafts/models.py b/seahub/drafts/models.py index c8a70f5d99..c3705efbfe 100644 --- a/seahub/drafts/models.py +++ b/seahub/drafts/models.py @@ -201,13 +201,34 @@ class DraftReview(TimestampedModel): } -###### signal handlers -from django.dispatch import receiver -from seahub.signals import repo_deleted +class ReviewCommentManager(models.Manager): + def add(self, comment, detail, author, review_id): + review_comment = self.model(author=author, comment=comment, + detail=detail, review_id=review_id) + review_comment.save(using=self._db) -@receiver(repo_deleted) -def remove_drafts(sender, **kwargs): - repo_owner = kwargs['repo_owner'] - repo_id = kwargs['repo_id'] + return review_comment - Draft.objects.filter(username=repo_owner, draft_repo_id=repo_id).delete() + +class ReviewComment(TimestampedModel): + """ + Model used to record file comments. + """ + author = LowerCaseCharField(max_length=255, db_index=True) + resolved = models.BooleanField(default=False, db_index=True) + review_id = models.ForeignKey('DraftReview', on_delete=models.CASCADE) + comment = models.TextField() + detail = models.TextField() + + objects = ReviewCommentManager() + + def to_dict(self): + return { + 'id': self.pk, + 'review_id': self.review_id_id, + 'comment': self.comment, + 'created_at': datetime_to_isoformat_timestr(self.created_at), + 'updated_at': datetime_to_isoformat_timestr(self.updated_at), + 'resolved': self.resolved, + 'detail': self.detail, + } diff --git a/seahub/drafts/signals.py b/seahub/drafts/signals.py new file mode 100644 index 0000000000..3ab58b9f59 --- /dev/null +++ b/seahub/drafts/signals.py @@ -0,0 +1,4 @@ +# Copyright (c) 2012-2018 Seafile Ltd. +import django.dispatch + +comment_review_successful = django.dispatch.Signal(providing_args=["review", "comment", "author"]) diff --git a/seahub/notifications/models.py b/seahub/notifications/models.py index 13625c6a6a..61fd3ee8f4 100644 --- a/seahub/notifications/models.py +++ b/seahub/notifications/models.py @@ -53,6 +53,7 @@ MSG_TYPE_REPO_SHARE = 'repo_share' MSG_TYPE_REPO_SHARE_TO_GROUP = 'repo_share_to_group' MSG_TYPE_USER_MESSAGE = 'user_message' MSG_TYPE_FILE_COMMENT = 'file_comment' +MSG_TYPE_REVIEW_COMMENT = 'review_comment' MSG_TYPE_GUEST_INVITATION_ACCEPTED = 'guest_invitation_accepted' USER_NOTIFICATION_COUNT_CACHE_PREFIX = 'USER_NOTIFICATION_COUNT_' @@ -92,6 +93,11 @@ def file_comment_msg_to_json(repo_id, file_path, author, comment): 'author': author, 'comment': comment}) +def review_comment_msg_to_json(review_id, author, comment): + return json.dumps({'review_id': review_id, + 'author': author, + 'comment': comment}) + def guest_invitation_accepted_msg_to_json(invitation_id): return json.dumps({'invitation_id': invitation_id}) @@ -287,6 +293,11 @@ class UserNotificationManager(models.Manager): """ return self._add_user_notification(to_user, MSG_TYPE_FILE_COMMENT, detail) + def add_review_comment_msg(self, to_user, detail): + """Notify ``to_user`` that review creator + """ + return self._add_user_notification(to_user, MSG_TYPE_REVIEW_COMMENT, detail) + def add_guest_invitation_accepted_msg(self, to_user, detail): """Nofity ``to_user`` that a guest has accpeted an invitation. """ @@ -385,6 +396,9 @@ class UserNotification(models.Model): def is_file_comment_msg(self): return self.msg_type == MSG_TYPE_FILE_COMMENT + def is_review_comment_msg(self): + return self.msg_type == MSG_TYPE_REVIEW_COMMENT + def is_guest_invitation_accepted_msg(self): return self.msg_type == MSG_TYPE_GUEST_INVITATION_ACCEPTED @@ -729,6 +743,23 @@ class UserNotification(models.Model): } return msg + def format_review_comment_msg(self): + try: + d = json.loads(self.detail) + except Exception as e: + logger.error(e) + return _(u"Internal error") + + review_id = d['review_id'] + author = d['author'] + + msg = _("Review %(review_id)s has a new comment from user %(author)s") % { + 'review_id': review_id, + 'file_url': reverse('drafts:review', args=[review_id]), + 'author': escape(email2nickname(author)), + } + return msg + def format_guest_invitation_accepted_msg(self): try: d = json.loads(self.detail) @@ -763,6 +794,7 @@ from seahub.group.signals import grpmsg_added, group_join_request, add_user_to_g from seahub.share.signals import share_repo_to_user_successful, \ share_repo_to_group_successful from seahub.invitations.signals import accept_guest_invitation_successful +from seahub.drafts.signals import comment_review_successful @receiver(upload_file_successful) def add_upload_file_msg_cb(sender, **kwargs): @@ -867,6 +899,16 @@ def comment_file_successful_cb(sender, **kwargs): detail = file_comment_msg_to_json(repo.id, file_path, author, comment) UserNotification.objects.add_file_comment_msg(u, detail) +@receiver(comment_review_successful) +def comment_review_successful_cb(sender, **kwargs): + review = kwargs['review'] + comment = kwargs['comment'] + author = kwargs['author'] + + detail = review_comment_msg_to_json(review.id, author, comment) + UserNotification.objects.add_review_comment_msg(review.creator, detail) + + @receiver(accept_guest_invitation_successful) def accept_guest_invitation_successful_cb(sender, **kwargs): inv_obj = kwargs['invitation_obj'] diff --git a/seahub/notifications/templates/notifications/user_notification_tr.html b/seahub/notifications/templates/notifications/user_notification_tr.html index 39b23576d8..e3407f3246 100644 --- a/seahub/notifications/templates/notifications/user_notification_tr.html +++ b/seahub/notifications/templates/notifications/user_notification_tr.html @@ -31,9 +31,13 @@ {% elif notice.is_group_join_request %}

    {{ notice.format_group_join_request|safe }}

    + {% elif notice.is_file_comment_msg %}

    {{ notice.format_file_comment_msg|safe }}

    + {% elif notice.is_review_comment_msg %} +

    {{ notice.format_review_comment_msg|safe }}

    + {% elif notice.is_guest_invitation_accepted_msg %}

    {{ notice.format_guest_invitation_accepted_msg|safe }}

    diff --git a/seahub/notifications/views.py b/seahub/notifications/views.py index 7eaae90a20..40d02567d2 100644 --- a/seahub/notifications/views.py +++ b/seahub/notifications/views.py @@ -185,4 +185,12 @@ def add_notice_from_info(notices): except Exception as e: logger.error(e) + elif notice.is_review_comment_msg(): + try: + d = json.loads(notice.detail) + notice.msg_from = d['author'] + except Exception as e: + logger.error(e) + + return notices diff --git a/seahub/templates/snippets/notice_html.html b/seahub/templates/snippets/notice_html.html index fe65bac56d..29ec3051dd 100644 --- a/seahub/templates/snippets/notice_html.html +++ b/seahub/templates/snippets/notice_html.html @@ -37,6 +37,9 @@ {% elif notice.is_file_comment_msg %}

    {{ notice.format_file_comment_msg|safe }}

    + {% elif notice.is_review_comment_msg %} +

    {{ notice.format_review_comment_msg|safe }}

    + {% elif notice.is_guest_invitation_accepted_msg %}

    {{ notice.format_guest_invitation_accepted_msg|safe }}