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) &&
+
+ }
+
+ { (this.state.commentsList.length > 0 && this.props.commentsNumber > 0) &&
+ this.state.commentsList.map((item, index = 0, arr) => {
+ if (item.resolved) {
+ return
+ } else {
+ let oldTime = (new Date(item.created_at)).getTime();
+ let time = moment(oldTime).format("YYYY-MM-DD HH:mm");
+ return (
+
+ )
+ }
+ })
+ }
+
+
+
+

+
+
+
+
+
+
+
+ )
+ }
+}
+
+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 }}