comments: edit functionality added

This commit is contained in:
Bartłomiej Wołyńczyk 2020-05-27 23:47:09 +02:00
parent f91a6454e7
commit b9a819cbfe
18 changed files with 6683 additions and 32 deletions

View file

@ -48,7 +48,7 @@ PYRAMID_SETTINGS = {}
EXTENSIONS = {}
__version__ = ('.'.join((str(each) for each in VERSION[:3])))
__dbversion__ = 107 # defines current db version for migrations
__dbversion__ = 108 # defines current db version for migrations
__platform__ = platform.system()
__license__ = 'AGPLv3, and Commercial License'
__author__ = 'RhodeCode GmbH'

View file

@ -78,6 +78,10 @@ def includeme(config):
name='repo_commit_comment_preview',
pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/preview', repo_route=True)
config.add_route(
name='repo_commit_comment_history_view',
pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_history_id}/history_view', repo_route=True)
config.add_route(
name='repo_commit_comment_attachment_upload',
pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/attachment_upload', repo_route=True)
@ -86,6 +90,10 @@ def includeme(config):
name='repo_commit_comment_delete',
pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_id}/delete', repo_route=True)
config.add_route(
name='repo_commit_comment_edit',
pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_id}/edit', repo_route=True)
# still working url for backward compat.
config.add_route(
name='repo_commit_raw_deprecated',
@ -327,6 +335,11 @@ def includeme(config):
pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/comment',
repo_route=True)
config.add_route(
name='pullrequest_comment_edit',
pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/comment/{comment_id}/edit',
repo_route=True, repo_accepted_types=['hg', 'git'])
config.add_route(
name='pullrequest_comment_delete',
pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/comment/{comment_id}/delete',

View file

@ -35,6 +35,7 @@ def route_path(name, params=None, **kwargs):
'repo_commit_comment_create': '/{repo_name}/changeset/{commit_id}/comment/create',
'repo_commit_comment_preview': '/{repo_name}/changeset/{commit_id}/comment/preview',
'repo_commit_comment_delete': '/{repo_name}/changeset/{commit_id}/comment/{comment_id}/delete',
'repo_commit_comment_edit': '/{repo_name}/changeset/{commit_id}/comment/{comment_id}/edit',
}[name].format(**kwargs)
if params:
@ -268,6 +269,164 @@ class TestRepoCommitCommentsView(TestController):
repo_name=backend.repo_name, commit_id=commit_id))
assert_comment_links(response, 0, 0)
def test_edit(self, backend):
self.log_user()
commit_id = backend.repo.get_commit('300').raw_id
text = u'CommentOnCommit'
params = {'text': text, 'csrf_token': self.csrf_token}
self.app.post(
route_path(
'repo_commit_comment_create',
repo_name=backend.repo_name, commit_id=commit_id),
params=params)
comments = ChangesetComment.query().all()
assert len(comments) == 1
comment_id = comments[0].comment_id
test_text = 'test_text'
self.app.post(
route_path(
'repo_commit_comment_edit',
repo_name=backend.repo_name,
commit_id=commit_id,
comment_id=comment_id,
),
params={
'csrf_token': self.csrf_token,
'text': test_text,
'version': '0',
})
text_form_db = ChangesetComment.query().filter(
ChangesetComment.comment_id == comment_id).first().text
assert test_text == text_form_db
def test_edit_without_change(self, backend):
self.log_user()
commit_id = backend.repo.get_commit('300').raw_id
text = u'CommentOnCommit'
params = {'text': text, 'csrf_token': self.csrf_token}
self.app.post(
route_path(
'repo_commit_comment_create',
repo_name=backend.repo_name, commit_id=commit_id),
params=params)
comments = ChangesetComment.query().all()
assert len(comments) == 1
comment_id = comments[0].comment_id
response = self.app.post(
route_path(
'repo_commit_comment_edit',
repo_name=backend.repo_name,
commit_id=commit_id,
comment_id=comment_id,
),
params={
'csrf_token': self.csrf_token,
'text': text,
'version': '0',
},
status=404,
)
assert response.status_int == 404
def test_edit_try_edit_already_edited(self, backend):
self.log_user()
commit_id = backend.repo.get_commit('300').raw_id
text = u'CommentOnCommit'
params = {'text': text, 'csrf_token': self.csrf_token}
self.app.post(
route_path(
'repo_commit_comment_create',
repo_name=backend.repo_name, commit_id=commit_id
),
params=params,
)
comments = ChangesetComment.query().all()
assert len(comments) == 1
comment_id = comments[0].comment_id
test_text = 'test_text'
self.app.post(
route_path(
'repo_commit_comment_edit',
repo_name=backend.repo_name,
commit_id=commit_id,
comment_id=comment_id,
),
params={
'csrf_token': self.csrf_token,
'text': test_text,
'version': '0',
}
)
test_text_v2 = 'test_v2'
response = self.app.post(
route_path(
'repo_commit_comment_edit',
repo_name=backend.repo_name,
commit_id=commit_id,
comment_id=comment_id,
),
params={
'csrf_token': self.csrf_token,
'text': test_text_v2,
'version': '0',
},
status=404,
)
assert response.status_int == 404
text_form_db = ChangesetComment.query().filter(
ChangesetComment.comment_id == comment_id).first().text
assert test_text == text_form_db
assert test_text_v2 != text_form_db
def test_edit_forbidden_for_immutable_comments(self, backend):
self.log_user()
commit_id = backend.repo.get_commit('300').raw_id
text = u'CommentOnCommit'
params = {'text': text, 'csrf_token': self.csrf_token, 'version': '0'}
self.app.post(
route_path(
'repo_commit_comment_create',
repo_name=backend.repo_name,
commit_id=commit_id,
),
params=params
)
comments = ChangesetComment.query().all()
assert len(comments) == 1
comment_id = comments[0].comment_id
comment = ChangesetComment.get(comment_id)
comment.immutable_state = ChangesetComment.OP_IMMUTABLE
Session().add(comment)
Session().commit()
response = self.app.post(
route_path(
'repo_commit_comment_edit',
repo_name=backend.repo_name,
commit_id=commit_id,
comment_id=comment_id,
),
params={
'csrf_token': self.csrf_token,
'text': 'test_text',
},
status=403,
)
assert response.status_int == 403
def test_delete_forbidden_for_immutable_comments(self, backend):
self.log_user()
commit_id = backend.repo.get_commit('300').raw_id

View file

@ -30,6 +30,7 @@ from rhodecode.model.db import (
from rhodecode.model.meta import Session
from rhodecode.model.pull_request import PullRequestModel
from rhodecode.model.user import UserModel
from rhodecode.model.comment import CommentsModel
from rhodecode.tests import (
assert_session_flash, TEST_USER_ADMIN_LOGIN, TEST_USER_REGULAR_LOGIN)
@ -54,6 +55,7 @@ def route_path(name, params=None, **kwargs):
'pullrequest_delete': '/{repo_name}/pull-request/{pull_request_id}/delete',
'pullrequest_comment_create': '/{repo_name}/pull-request/{pull_request_id}/comment',
'pullrequest_comment_delete': '/{repo_name}/pull-request/{pull_request_id}/comment/{comment_id}/delete',
'pullrequest_comment_edit': '/{repo_name}/pull-request/{pull_request_id}/comment/{comment_id}/edit',
}[name].format(**kwargs)
if params:
@ -338,8 +340,8 @@ class TestPullrequestsView(object):
response = self.app.post(
route_path('pullrequest_comment_create',
repo_name=pull_request.target_repo.scm_instance().name,
pull_request_id=pull_request.pull_request_id),
repo_name=pull_request.target_repo.scm_instance().name,
pull_request_id=pull_request.pull_request_id),
params={
'close_pull_request': 'true',
'csrf_token': csrf_token},
@ -355,6 +357,214 @@ class TestPullrequestsView(object):
pull_request.source_repo, pull_request=pull_request)
assert status == ChangesetStatus.STATUS_REJECTED
def test_comment_and_close_pull_request_try_edit_comment(
self, pr_util, csrf_token, xhr_header
):
pull_request = pr_util.create_pull_request()
pull_request_id = pull_request.pull_request_id
response = self.app.post(
route_path(
'pullrequest_comment_create',
repo_name=pull_request.target_repo.scm_instance().name,
pull_request_id=pull_request.pull_request_id,
),
params={
'close_pull_request': 'true',
'csrf_token': csrf_token,
},
extra_environ=xhr_header)
assert response.json
pull_request = PullRequest.get(pull_request_id)
assert pull_request.is_closed()
# check only the latest status, not the review status
status = ChangesetStatusModel().get_status(
pull_request.source_repo, pull_request=pull_request)
assert status == ChangesetStatus.STATUS_REJECTED
comment_id = response.json.get('comment_id', None)
test_text = 'test'
response = self.app.post(
route_path(
'pullrequest_comment_edit',
repo_name=pull_request.target_repo.scm_instance().name,
pull_request_id=pull_request.pull_request_id,
comment_id=comment_id,
),
extra_environ=xhr_header,
params={
'csrf_token': csrf_token,
'text': test_text,
},
status=403,
)
assert response.status_int == 403
def test_comment_and_comment_edit(
self, pr_util, csrf_token, xhr_header
):
pull_request = pr_util.create_pull_request()
response = self.app.post(
route_path(
'pullrequest_comment_create',
repo_name=pull_request.target_repo.scm_instance().name,
pull_request_id=pull_request.pull_request_id),
params={
'csrf_token': csrf_token,
'text': 'init',
},
extra_environ=xhr_header,
)
assert response.json
comment_id = response.json.get('comment_id', None)
assert comment_id
test_text = 'test'
self.app.post(
route_path(
'pullrequest_comment_edit',
repo_name=pull_request.target_repo.scm_instance().name,
pull_request_id=pull_request.pull_request_id,
comment_id=comment_id,
),
extra_environ=xhr_header,
params={
'csrf_token': csrf_token,
'text': test_text,
'version': '0',
},
)
text_form_db = ChangesetComment.query().filter(
ChangesetComment.comment_id == comment_id).first().text
assert test_text == text_form_db
def test_comment_and_comment_edit(
self, pr_util, csrf_token, xhr_header
):
pull_request = pr_util.create_pull_request()
response = self.app.post(
route_path(
'pullrequest_comment_create',
repo_name=pull_request.target_repo.scm_instance().name,
pull_request_id=pull_request.pull_request_id),
params={
'csrf_token': csrf_token,
'text': 'init',
},
extra_environ=xhr_header,
)
assert response.json
comment_id = response.json.get('comment_id', None)
assert comment_id
test_text = 'init'
response = self.app.post(
route_path(
'pullrequest_comment_edit',
repo_name=pull_request.target_repo.scm_instance().name,
pull_request_id=pull_request.pull_request_id,
comment_id=comment_id,
),
extra_environ=xhr_header,
params={
'csrf_token': csrf_token,
'text': test_text,
'version': '0',
},
status=404,
)
assert response.status_int == 404
def test_comment_and_try_edit_already_edited(
self, pr_util, csrf_token, xhr_header
):
pull_request = pr_util.create_pull_request()
response = self.app.post(
route_path(
'pullrequest_comment_create',
repo_name=pull_request.target_repo.scm_instance().name,
pull_request_id=pull_request.pull_request_id),
params={
'csrf_token': csrf_token,
'text': 'init',
},
extra_environ=xhr_header,
)
assert response.json
comment_id = response.json.get('comment_id', None)
assert comment_id
test_text = 'test'
response = self.app.post(
route_path(
'pullrequest_comment_edit',
repo_name=pull_request.target_repo.scm_instance().name,
pull_request_id=pull_request.pull_request_id,
comment_id=comment_id,
),
extra_environ=xhr_header,
params={
'csrf_token': csrf_token,
'text': test_text,
'version': '0',
},
)
test_text_v2 = 'test_v2'
response = self.app.post(
route_path(
'pullrequest_comment_edit',
repo_name=pull_request.target_repo.scm_instance().name,
pull_request_id=pull_request.pull_request_id,
comment_id=comment_id,
),
extra_environ=xhr_header,
params={
'csrf_token': csrf_token,
'text': test_text_v2,
'version': '0',
},
status=404,
)
assert response.status_int == 404
text_form_db = ChangesetComment.query().filter(
ChangesetComment.comment_id == comment_id).first().text
assert test_text == text_form_db
assert test_text_v2 != text_form_db
def test_comment_and_comment_edit_permissions_forbidden(
self, autologin_regular_user, user_regular, user_admin, pr_util,
csrf_token, xhr_header):
pull_request = pr_util.create_pull_request(
author=user_admin.username, enable_notifications=False)
comment = CommentsModel().create(
text='test',
repo=pull_request.target_repo.scm_instance().name,
user=user_admin,
pull_request=pull_request,
)
response = self.app.post(
route_path(
'pullrequest_comment_edit',
repo_name=pull_request.target_repo.scm_instance().name,
pull_request_id=pull_request.pull_request_id,
comment_id=comment.comment_id,
),
extra_environ=xhr_header,
params={
'csrf_token': csrf_token,
'text': 'test_text',
},
status=403,
)
assert response.status_int == 403
def test_create_pull_request(self, backend, csrf_token):
commits = [
{'message': 'ancestor'},

View file

@ -45,7 +45,8 @@ from rhodecode.lib.utils2 import safe_unicode, str2bool
from rhodecode.lib.vcs.backends.base import EmptyCommit
from rhodecode.lib.vcs.exceptions import (
RepositoryError, CommitDoesNotExistError)
from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore
from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore, \
ChangesetCommentHistory
from rhodecode.model.changeset_status import ChangesetStatusModel
from rhodecode.model.comment import CommentsModel
from rhodecode.model.meta import Session
@ -425,6 +426,27 @@ class RepoCommitsView(RepoAppView):
repo_name=self.db_repo_name)
return ''
@LoginRequired()
@NotAnonymous()
@HasRepoPermissionAnyDecorator(
'repository.read', 'repository.write', 'repository.admin')
@CSRFRequired()
@view_config(
route_name='repo_commit_comment_history_view', request_method='POST',
renderer='string', xhr=True)
def repo_commit_comment_history_view(self):
commit_id = self.request.matchdict['commit_id']
comment_history_id = self.request.matchdict['comment_history_id']
comment_history = ChangesetCommentHistory.get_or_404(comment_history_id)
c = self.load_default_context()
c.comment_history = comment_history
rendered_comment = render(
'rhodecode:templates/changeset/comment_history.mako',
self._get_template_context(c)
, self.request)
return rendered_comment
@LoginRequired()
@NotAnonymous()
@HasRepoPermissionAnyDecorator(
@ -557,6 +579,74 @@ class RepoCommitsView(RepoAppView):
self._rhodecode_db_user, comment_id)
raise HTTPNotFound()
@LoginRequired()
@NotAnonymous()
@HasRepoPermissionAnyDecorator(
'repository.read', 'repository.write', 'repository.admin')
@CSRFRequired()
@view_config(
route_name='repo_commit_comment_edit', request_method='POST',
renderer='json_ext')
def repo_commit_comment_edit(self):
commit_id = self.request.matchdict['commit_id']
comment_id = self.request.matchdict['comment_id']
comment = ChangesetComment.get_or_404(comment_id)
if comment.immutable:
# don't allow deleting comments that are immutable
raise HTTPForbidden()
is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
super_admin = h.HasPermissionAny('hg.admin')()
comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
is_repo_comment = comment.repo.repo_name == self.db_repo_name
comment_repo_admin = is_repo_admin and is_repo_comment
if super_admin or comment_owner or comment_repo_admin:
text = self.request.POST.get('text')
version = self.request.POST.get('version')
if text == comment.text:
log.warning(
'Comment(repo): '
'Trying to create new version '
'of existing comment {}'.format(
comment_id,
)
)
raise HTTPNotFound()
if version.isdigit():
version = int(version)
else:
log.warning(
'Comment(repo): Wrong version type {} {} '
'for comment {}'.format(
version,
type(version),
comment_id,
)
)
raise HTTPNotFound()
comment_history = CommentsModel().edit(
comment_id=comment_id,
text=text,
auth_user=self._rhodecode_user,
version=version,
)
if not comment_history:
raise HTTPNotFound()
Session().commit()
return {
'comment_history_id': comment_history.comment_history_id,
'comment_id': comment.comment_id,
'comment_version': comment_history.version,
}
else:
log.warning('No permissions for user %s to edit comment_id: %s',
self._rhodecode_db_user, comment_id)
raise HTTPNotFound()
@LoginRequired()
@HasRepoPermissionAnyDecorator(
'repository.read', 'repository.write', 'repository.admin')

View file

@ -1518,3 +1518,90 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView):
log.warning('No permissions for user %s to delete comment_id: %s',
self._rhodecode_db_user, comment_id)
raise HTTPNotFound()
@LoginRequired()
@NotAnonymous()
@HasRepoPermissionAnyDecorator(
'repository.read', 'repository.write', 'repository.admin')
@CSRFRequired()
@view_config(
route_name='pullrequest_comment_edit', request_method='POST',
renderer='json_ext')
def pull_request_comment_edit(self):
pull_request = PullRequest.get_or_404(
self.request.matchdict['pull_request_id']
)
comment = ChangesetComment.get_or_404(
self.request.matchdict['comment_id']
)
comment_id = comment.comment_id
if comment.immutable:
# don't allow deleting comments that are immutable
raise HTTPForbidden()
if pull_request.is_closed():
log.debug('comment: forbidden because pull request is closed')
raise HTTPForbidden()
if not comment:
log.debug('Comment with id:%s not found, skipping', comment_id)
# comment already deleted in another call probably
return True
if comment.pull_request.is_closed():
# don't allow deleting comments on closed pull request
raise HTTPForbidden()
is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
super_admin = h.HasPermissionAny('hg.admin')()
comment_owner = comment.author.user_id == self._rhodecode_user.user_id
is_repo_comment = comment.repo.repo_name == self.db_repo_name
comment_repo_admin = is_repo_admin and is_repo_comment
if super_admin or comment_owner or comment_repo_admin:
text = self.request.POST.get('text')
version = self.request.POST.get('version')
if text == comment.text:
log.warning(
'Comment(PR): '
'Trying to create new version '
'of existing comment {}'.format(
comment_id,
)
)
raise HTTPNotFound()
if version.isdigit():
version = int(version)
else:
log.warning(
'Comment(PR): Wrong version type {} {} '
'for comment {}'.format(
version,
type(version),
comment_id,
)
)
raise HTTPNotFound()
comment_history = CommentsModel().edit(
comment_id=comment_id,
text=text,
auth_user=self._rhodecode_user,
version=version,
)
if not comment_history:
raise HTTPNotFound()
Session().commit()
return {
'comment_history_id': comment_history.comment_history_id,
'comment_id': comment.comment_id,
'comment_version': comment_history.version,
}
else:
log.warning(
'No permissions for user {} to edit comment_id: {}'.format(
self._rhodecode_db_user, comment_id
)
)
raise HTTPNotFound()

View file

@ -82,6 +82,7 @@ ACTIONS_V1 = {
'repo.pull_request.merge': '',
'repo.pull_request.vote': '',
'repo.pull_request.comment.create': '',
'repo.pull_request.comment.edit': '',
'repo.pull_request.comment.delete': '',
'repo.pull_request.reviewer.add': '',
@ -90,6 +91,7 @@ ACTIONS_V1 = {
'repo.commit.strip': {'commit_id': ''},
'repo.commit.comment.create': {'data': {}},
'repo.commit.comment.delete': {'data': {}},
'repo.commit.comment.edit': {'data': {}},
'repo.commit.vote': '',
'repo.artifact.add': '',

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
import logging
from sqlalchemy import *
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import BigInteger
from rhodecode.lib.dbmigrate.versions import _reset_base
from rhodecode.model import init_model_encryption
log = logging.getLogger(__name__)
def upgrade(migrate_engine):
"""
Upgrade operations go here.
Don't create your own engine; bind migrate_engine to your metadata
"""
_reset_base(migrate_engine)
from rhodecode.lib.dbmigrate.schema import db_4_19_0_2 as db
init_model_encryption(db)
db.ChangesetCommentHistory().__table__.create()
def downgrade(migrate_engine):
meta = MetaData()
meta.bind = migrate_engine
def fixups(models, _SESSION):
pass

View file

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2020-2020 RhodeCode GmbH
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License, version 3
# (only), as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# This program is dual-licensed. If you wish to learn more about the
# RhodeCode Enterprise Edition, including its added features, Support services,
# and proprietary license terms, please see https://rhodecode.com/licenses/
## base64 filter e.g ${ example | base64,n }
def base64(text):
import base64
from rhodecode.lib.helpers import safe_str
return base64.encodestring(safe_str(text))

View file

@ -35,7 +35,13 @@ from rhodecode.lib import audit_logger
from rhodecode.lib.utils2 import extract_mentioned_users, safe_str
from rhodecode.model import BaseModel
from rhodecode.model.db import (
ChangesetComment, User, Notification, PullRequest, AttributeDict)
ChangesetComment,
User,
Notification,
PullRequest,
AttributeDict,
ChangesetCommentHistory,
)
from rhodecode.model.notification import NotificationModel
from rhodecode.model.meta import Session
from rhodecode.model.settings import VcsSettingsModel
@ -479,6 +485,54 @@ class CommentsModel(BaseModel):
return comment
def edit(self, comment_id, text, auth_user, version):
"""
Change existing comment for commit or pull request.
:param comment_id:
:param text:
:param auth_user: current authenticated user calling this method
:param version: last comment version
"""
if not text:
log.warning('Missing text for comment, skipping...')
return
comment = ChangesetComment.get(comment_id)
old_comment_text = comment.text
comment.text = text
comment_version = ChangesetCommentHistory.get_version(comment_id)
if (comment_version - version) != 1:
log.warning(
'Version mismatch, skipping... '
'version {} but should be {}'.format(
(version - 1),
comment_version,
)
)
return
comment_history = ChangesetCommentHistory()
comment_history.comment_id = comment_id
comment_history.version = comment_version
comment_history.created_by_user_id = auth_user.user_id
comment_history.text = old_comment_text
# TODO add email notification
Session().add(comment_history)
Session().add(comment)
Session().flush()
if comment.pull_request:
action = 'repo.pull_request.comment.edit'
else:
action = 'repo.commit.comment.edit'
comment_data = comment.get_api_data()
comment_data['old_comment_text'] = old_comment_text
self._log_audit_action(
action, {'data': comment_data}, auth_user, comment)
return comment_history
def delete(self, comment, auth_user):
"""
Deletes given comment
@ -712,6 +766,7 @@ class CommentsModel(BaseModel):
.filter(ChangesetComment.line_no == None)\
.filter(ChangesetComment.f_path == None)\
.filter(ChangesetComment.pull_request == pull_request)
return comments
@staticmethod

View file

@ -3755,6 +3755,7 @@ class ChangesetComment(Base, BaseModel):
status_change = relationship('ChangesetStatus', cascade="all, delete-orphan", lazy='joined')
pull_request = relationship('PullRequest', lazy='joined')
pull_request_version = relationship('PullRequestVersion')
history = relationship('ChangesetCommentHistory', cascade='all, delete-orphan', lazy='joined', order_by='ChangesetCommentHistory.version')
@classmethod
def get_users(cls, revision=None, pull_request_id=None):
@ -3849,6 +3850,36 @@ class ChangesetComment(Base, BaseModel):
return data
class ChangesetCommentHistory(Base, BaseModel):
__tablename__ = 'changeset_comments_history'
__table_args__ = (
Index('cch_comment_id_idx', 'comment_id'),
base_table_args,
)
comment_history_id = Column('comment_history_id', Integer(), nullable=False, primary_key=True)
comment_id = Column('comment_id', Integer(), ForeignKey('changeset_comments.comment_id'), nullable=False)
version = Column("version", Integer(), nullable=False, default=0)
created_by_user_id = Column('created_by_user_id', Integer(), ForeignKey('users.user_id'), nullable=False)
text = Column('text', UnicodeText().with_variant(UnicodeText(25000), 'mysql'), nullable=False)
created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
deleted = Column('deleted', Boolean(), default=False)
author = relationship('User', lazy='joined')
comment = relationship('ChangesetComment', cascade="all, delete")
@classmethod
def get_version(cls, comment_id):
q = Session().query(ChangesetCommentHistory).filter(
ChangesetCommentHistory.comment_id == comment_id).order_by(ChangesetCommentHistory.version.desc())
if q.count() == 0:
return 1
elif q.count() >= q[0].version:
return q.count() + 1
else:
return q[0].version + 1
class ChangesetStatus(Base, BaseModel):
__tablename__ = 'changeset_statuses'
__table_args__ = (

View file

@ -185,8 +185,10 @@ function registerRCRoutes() {
pyroutes.register('repo_commit_data', '/%(repo_name)s/changeset-data/%(commit_id)s', ['repo_name', 'commit_id']);
pyroutes.register('repo_commit_comment_create', '/%(repo_name)s/changeset/%(commit_id)s/comment/create', ['repo_name', 'commit_id']);
pyroutes.register('repo_commit_comment_preview', '/%(repo_name)s/changeset/%(commit_id)s/comment/preview', ['repo_name', 'commit_id']);
pyroutes.register('repo_commit_comment_history_view', '/%(repo_name)s/changeset/%(commit_id)s/comment/%(comment_history_id)s/history_view', ['repo_name', 'commit_id', 'comment_history_id']);
pyroutes.register('repo_commit_comment_attachment_upload', '/%(repo_name)s/changeset/%(commit_id)s/comment/attachment_upload', ['repo_name', 'commit_id']);
pyroutes.register('repo_commit_comment_delete', '/%(repo_name)s/changeset/%(commit_id)s/comment/%(comment_id)s/delete', ['repo_name', 'commit_id', 'comment_id']);
pyroutes.register('repo_commit_comment_edit', '/%(repo_name)s/changeset/%(commit_id)s/comment/%(comment_id)s/edit', ['repo_name', 'commit_id', 'comment_id']);
pyroutes.register('repo_commit_raw_deprecated', '/%(repo_name)s/raw-changeset/%(commit_id)s', ['repo_name', 'commit_id']);
pyroutes.register('repo_archivefile', '/%(repo_name)s/archive/%(fname)s', ['repo_name', 'fname']);
pyroutes.register('repo_files_diff', '/%(repo_name)s/diff/%(f_path)s', ['repo_name', 'f_path']);
@ -242,6 +244,7 @@ function registerRCRoutes() {
pyroutes.register('pullrequest_merge', '/%(repo_name)s/pull-request/%(pull_request_id)s/merge', ['repo_name', 'pull_request_id']);
pyroutes.register('pullrequest_delete', '/%(repo_name)s/pull-request/%(pull_request_id)s/delete', ['repo_name', 'pull_request_id']);
pyroutes.register('pullrequest_comment_create', '/%(repo_name)s/pull-request/%(pull_request_id)s/comment', ['repo_name', 'pull_request_id']);
pyroutes.register('pullrequest_comment_edit', '/%(repo_name)s/pull-request/%(pull_request_id)s/comment/%(comment_id)s/edit', ['repo_name', 'pull_request_id', 'comment_id']);
pyroutes.register('pullrequest_comment_delete', '/%(repo_name)s/pull-request/%(pull_request_id)s/comment/%(comment_id)s/delete', ['repo_name', 'pull_request_id', 'comment_id']);
pyroutes.register('edit_repo', '/%(repo_name)s/settings', ['repo_name']);
pyroutes.register('edit_repo_advanced', '/%(repo_name)s/settings/advanced', ['repo_name']);

View file

@ -80,9 +80,10 @@ var _submitAjaxPOST = function(url, postData, successHandler, failHandler) {
})(function() {
"use strict";
function CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions, resolvesCommentId) {
function CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions, resolvesCommentId, edit, comment_id) {
if (!(this instanceof CommentForm)) {
return new CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions, resolvesCommentId);
return new CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions, resolvesCommentId, edit, comment_id);
}
// bind the element instance to our Form
@ -126,10 +127,22 @@ var _submitAjaxPOST = function(url, postData, successHandler, failHandler) {
this.submitButton = $(this.submitForm).find('input[type="submit"]');
this.submitButtonText = this.submitButton.val();
this.previewUrl = pyroutes.url('repo_commit_comment_preview',
{'repo_name': templateContext.repo_name,
'commit_id': templateContext.commit_data.commit_id});
if (edit){
this.submitButtonText = _gettext('Updated Comment');
$(this.commentType).prop('disabled', true);
$(this.commentType).addClass('disabled');
var editInfo =
'<div class="comment-label note" id="comment-label-6" title="line: ">' +
'editing' +
'</div>';
$(editInfo).insertBefore($(this.editButton).parent());
}
if (resolvesCommentId){
this.resolvesId = '#resolve_comment_{0}'.format(resolvesCommentId);
this.resolvesActionId = '#resolve_comment_action_{0}'.format(resolvesCommentId);
@ -153,17 +166,27 @@ var _submitAjaxPOST = function(url, postData, successHandler, failHandler) {
// based on commitId, or pullRequestId decide where do we submit
// out data
if (this.commitId){
this.submitUrl = pyroutes.url('repo_commit_comment_create',
var pyurl = 'repo_commit_comment_create';
if(edit){
pyurl = 'repo_commit_comment_edit';
}
this.submitUrl = pyroutes.url(pyurl,
{'repo_name': templateContext.repo_name,
'commit_id': this.commitId});
'commit_id': this.commitId,
'comment_id': comment_id});
this.selfUrl = pyroutes.url('repo_commit',
{'repo_name': templateContext.repo_name,
'commit_id': this.commitId});
} else if (this.pullRequestId) {
this.submitUrl = pyroutes.url('pullrequest_comment_create',
var pyurl = 'pullrequest_comment_create';
if(edit){
pyurl = 'pullrequest_comment_edit';
}
this.submitUrl = pyroutes.url(pyurl,
{'repo_name': templateContext.repo_name,
'pull_request_id': this.pullRequestId});
'pull_request_id': this.pullRequestId,
'comment_id': comment_id});
this.selfUrl = pyroutes.url('pullrequest_show',
{'repo_name': templateContext.repo_name,
'pull_request_id': this.pullRequestId});
@ -277,7 +300,7 @@ var _submitAjaxPOST = function(url, postData, successHandler, failHandler) {
this.globalSubmitSuccessCallback = function(){
// default behaviour is to call GLOBAL hook, if it's registered.
if (window.commentFormGlobalSubmitSuccessCallback !== undefined){
commentFormGlobalSubmitSuccessCallback()
commentFormGlobalSubmitSuccessCallback();
}
};
@ -480,13 +503,75 @@ var CommentsController = function() {
var mainComment = '#text';
var self = this;
this.cancelComment = function(node) {
this.cancelComment = function (node) {
var $node = $(node);
var $td = $node.closest('td');
var edit = $(this).attr('edit');
if (edit) {
var $general_comments = null;
var $inline_comments = $node.closest('div.inline-comments');
if (!$inline_comments.length) {
$general_comments = $('#comments');
var $comment = $general_comments.parent().find('div.comment:hidden');
// show hidden general comment form
$('#cb-comment-general-form-placeholder').show();
} else {
var $comment = $inline_comments.find('div.comment:hidden');
}
$comment.show();
}
$node.closest('.comment-inline-form').remove();
return false;
};
this.showVersion = function (node) {
var $node = $(node);
var selectedIndex = $node.context.selectedIndex;
var option = $node.find('option[value="'+ selectedIndex +'"]');
var zero_option = $node.find('option[value="0"]');
if (!option){
return;
}
// little trick to cheat onchange and allow to display the same version again
$node.context.selectedIndex = 0;
zero_option.text(selectedIndex);
var comment_history_id = option.attr('data-comment-history-id');
var comment_id = option.attr('data-comment-id');
var historyViewUrl = pyroutes.url(
'repo_commit_comment_history_view',
{
'repo_name': templateContext.repo_name,
'commit_id': comment_id,
'comment_history_id': comment_history_id,
}
);
successRenderCommit = function (data) {
Swal.fire({
html: data,
title: '',
showClass: {
popup: 'swal2-noanimation',
backdrop: 'swal2-noanimation'
},
});
};
failRenderCommit = function () {
Swal.fire({
html: 'Error while loading comment',
title: '',
showClass: {
popup: 'swal2-noanimation',
backdrop: 'swal2-noanimation'
},
});
};
_submitAjaxPOST(
historyViewUrl, {'csrf_token': CSRF_TOKEN}, successRenderCommit,
failRenderCommit
);
};
this.getLineNumber = function(node) {
var $node = $(node);
var lineNo = $node.closest('td').attr('data-line-no');
@ -638,12 +723,12 @@ var CommentsController = function() {
$node.closest('tr').toggleClass('hide-line-comments');
};
this.createCommentForm = function(formElement, lineno, placeholderText, initAutocompleteActions, resolvesCommentId){
this.createCommentForm = function(formElement, lineno, placeholderText, initAutocompleteActions, resolvesCommentId, edit, comment_id){
var pullRequestId = templateContext.pull_request_data.pull_request_id;
var commitId = templateContext.commit_data.commit_id;
var commentForm = new CommentForm(
formElement, commitId, pullRequestId, lineno, initAutocompleteActions, resolvesCommentId);
formElement, commitId, pullRequestId, lineno, initAutocompleteActions, resolvesCommentId, edit, comment_id);
var cm = commentForm.getCmInstance();
if (resolvesCommentId){
@ -780,18 +865,200 @@ var CommentsController = function() {
var _form = $($form[0]);
var autocompleteActions = ['approve', 'reject', 'as_note', 'as_todo'];
var edit = false;
var comment_id = null;
var commentForm = this.createCommentForm(
_form, lineNo, placeholderText, autocompleteActions, resolvesCommentId);
_form, lineNo, placeholderText, autocompleteActions, resolvesCommentId, edit, comment_id);
commentForm.initStatusChangeSelector();
return commentForm;
};
this.editComment = function(node) {
var $node = $(node);
var $comment = $(node).closest('.comment');
var comment_id = $comment.attr('data-comment-id');
var $form = null
var $comments = $node.closest('div.inline-comments');
var $general_comments = null;
var lineno = null;
if($comments.length){
// inline comments setup
$form = $comments.find('.comment-inline-form');
lineno = self.getLineNumber(node)
}
else{
// general comments setup
$comments = $('#comments');
$form = $comments.find('.comment-inline-form');
lineno = $comment[0].id
$('#cb-comment-general-form-placeholder').hide();
}
this.edit = true;
if (!$form.length) {
var $filediff = $node.closest('.filediff');
$filediff.removeClass('hide-comments');
var f_path = $filediff.attr('data-f-path');
// create a new HTML from template
var tmpl = $('#cb-comment-inline-form-template').html();
tmpl = tmpl.format(escapeHtml(f_path), lineno);
$form = $(tmpl);
$comment.after($form)
var _form = $($form[0]).find('form');
var autocompleteActions = ['as_note',];
var commentForm = this.createCommentForm(
_form, lineno, '', autocompleteActions, resolvesCommentId,
this.edit, comment_id);
var old_comment_text_binary = $comment.attr('data-comment-text');
var old_comment_text = b64DecodeUnicode(old_comment_text_binary);
commentForm.cm.setValue(old_comment_text);
$comment.hide();
$.Topic('/ui/plugins/code/comment_form_built').prepareOrPublish({
form: _form,
parent: $comments,
lineno: lineno,
f_path: f_path}
);
// set a CUSTOM submit handler for inline comments.
commentForm.setHandleFormSubmit(function(o) {
var text = commentForm.cm.getValue();
var commentType = commentForm.getCommentType();
var resolvesCommentId = commentForm.getResolvesId();
if (text === "") {
return;
}
if (old_comment_text == text) {
Swal.fire({
title: 'Error',
html: _gettext('Comment body should be changed'),
showClass: {
popup: 'swal2-noanimation',
backdrop: 'swal2-noanimation'
},
});
return;
}
var excludeCancelBtn = false;
var submitEvent = true;
commentForm.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
commentForm.cm.setOption("readOnly", true);
var dropDown = $('#comment_history_for_comment_'+comment_id);
var version = dropDown.children().last().val()
if(!version){
version = 0;
}
var postData = {
'text': text,
'f_path': f_path,
'line': lineno,
'comment_type': commentType,
'csrf_token': CSRF_TOKEN,
'version': version,
};
var submitSuccessCallback = function(json_data) {
$form.remove();
$comment.show();
var postData = {
'text': text,
'renderer': $comment.attr('data-comment-renderer'),
'csrf_token': CSRF_TOKEN
};
var updateCommentVersionDropDown = function () {
var dropDown = $('#comment_history_for_comment_'+comment_id);
$comment.attr('data-comment-text', btoa(text));
var version = json_data['comment_version']
var option = new Option(version, version);
var $option = $(option);
$option.attr('data-comment-history-id', json_data['comment_history_id']);
$option.attr('data-comment-id', json_data['comment_id']);
dropDown.append(option);
dropDown.parent().show();
}
updateCommentVersionDropDown();
// by default we reset state of comment preserving the text
var failRenderCommit = function(jqXHR, textStatus, errorThrown) {
var prefix = "Error while editing of comment.\n"
var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
ajaxErrorSwal(message);
};
var successRenderCommit = function(o){
$comment.show();
$comment[0].lastElementChild.innerHTML = o;
}
var previewUrl = pyroutes.url('repo_commit_comment_preview',
{'repo_name': templateContext.repo_name,
'commit_id': templateContext.commit_data.commit_id});
_submitAjaxPOST(
previewUrl, postData, successRenderCommit,
failRenderCommit
);
try {
var html = json_data.rendered_text;
var lineno = json_data.line_no;
var target_id = json_data.target_id;
$comments.find('.cb-comment-add-button').before(html);
//mark visually which comment was resolved
if (resolvesCommentId) {
commentForm.markCommentResolved(resolvesCommentId);
}
// run global callback on submit
commentForm.globalSubmitSuccessCallback();
} catch (e) {
console.error(e);
}
// re trigger the linkification of next/prev navigation
linkifyComments($('.inline-comment-injected'));
timeagoActivate();
tooltipActivate();
if (window.updateSticky !== undefined) {
// potentially our comments change the active window size, so we
// notify sticky elements
updateSticky()
}
commentForm.setActionButtonsDisabled(false);
};
var submitFailCallback = function(jqXHR, textStatus, errorThrown) {
var prefix = "Error while editing comment.\n"
var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
ajaxErrorSwal(message);
commentForm.resetCommentFormState(text)
};
commentForm.submitAjaxPOST(
commentForm.submitUrl, postData, submitSuccessCallback, submitFailCallback);
});
}
$form.addClass('comment-inline-form-open');
};
this.createComment = function(node, resolutionComment) {
var resolvesCommentId = resolutionComment || null;
var $node = $(node);
var $td = $node.closest('td');
var $form = $td.find('.comment-inline-form');
this.edit = false;
if (!$form.length) {
@ -816,8 +1083,9 @@ var CommentsController = function() {
var placeholderText = _gettext('Leave a comment on line {0}.').format(lineno);
var _form = $($form[0]).find('form');
var autocompleteActions = ['as_note', 'as_todo'];
var comment_id=null;
var commentForm = this.createCommentForm(
_form, lineno, placeholderText, autocompleteActions, resolvesCommentId);
_form, lineno, placeholderText, autocompleteActions, resolvesCommentId, this.edit, comment_id);
$.Topic('/ui/plugins/code/comment_form_built').prepareOrPublish({
form: _form,

View file

@ -182,3 +182,9 @@ var htmlEnDeCode = (function() {
htmlDecode: htmlDecode
};
})();
function b64DecodeUnicode(str) {
return decodeURIComponent(atob(str).split('').map(function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
}

View file

@ -1,11 +1,7 @@
## -*- coding: utf-8 -*-
<%!
## base64 filter e.g ${ example | base64 }
def base64(text):
import base64
from rhodecode.lib.helpers import safe_str
return base64.encodestring(safe_str(text))
from rhodecode.lib import html_filters
%>
<%inherit file="root.mako"/>

View file

@ -3,8 +3,12 @@
## <%namespace name="comment" file="/changeset/changeset_file_comment.mako"/>
## ${comment.comment_block(comment)}
##
<%namespace name="base" file="/base/base.mako"/>
<%!
from rhodecode.lib import html_filters
%>
<%namespace name="base" file="/base/base.mako"/>
<%def name="comment_block(comment, inline=False, active_pattern_entries=None)">
<% pr_index_ver = comment.get_index_version(getattr(c, 'versions', [])) %>
<% latest_ver = len(getattr(c, 'versions', [])) %>
@ -21,6 +25,8 @@
line="${comment.line_no}"
data-comment-id="${comment.comment_id}"
data-comment-type="${comment.comment_type}"
data-comment-renderer="${comment.renderer}"
data-comment-text="${comment.text | html_filters.base64,n}"
data-comment-line-no="${comment.line_no}"
data-comment-inline=${h.json.dumps(inline)}
style="${'display: none;' if outdated_at_ver else ''}">
@ -60,6 +66,31 @@
<div class="date">
${h.age_component(comment.modified_at, time_is_local=True)}
</div>
% if comment.history:
<div class="date">
<span class="comment-area-text">${_('Comment version')}:</span>
<select class="comment-type" id="comment_history_for_comment_${comment.comment_id}"
onchange="return Rhodecode.comments.showVersion(this)"
name="comment_type">
<option style="display: none" value="0">---</option>
%for comment_history in comment.history:
<option
data-comment-history-id="${comment_history.comment_history_id}",
data-comment-id="${comment.comment_id}",
value="${comment_history.version}">${comment_history.version}</option>
%endfor
</select>
</div>
% else:
<div class="date" style="display: none">
<span class="comment-area-text">${_('Comment version')}</span>
<select class="comment-type" id="comment_history_for_comment_${comment.comment_id}"
onchange="return Rhodecode.comments.showVersion(this)"
name="comment_type">
<option style="display: none" value="0">---</option>
</select>
</div>
%endif
% if inline:
<span></span>
% else:
@ -136,21 +167,29 @@
%if not outdated_at_ver and (not comment.pull_request or (comment.pull_request and not comment.pull_request.is_closed())):
## permissions to delete
%if comment.immutable is False and (c.is_super_admin or h.HasRepoPermissionAny('repository.admin')(c.repo_name) or comment.author.user_id == c.rhodecode_user.user_id):
## TODO: dan: add edit comment here
<a onclick="return Rhodecode.comments.deleteComment(this);" class="delete-comment"> ${_('Delete')}</a>
%if comment.comment_type == 'note':
<a onclick="return Rhodecode.comments.editComment(this);"
class="edit-comment"> ${_('Edit')}</a>
%else:
<button class="btn-link" disabled="disabled"> ${_('Edit')}</button>
%endif
| <a onclick="return Rhodecode.comments.deleteComment(this);"
class="delete-comment"> ${_('Delete')}</a>
%else:
<button class="btn-link" disabled="disabled"> ${_('Delete')}</button>
<button class="btn-link" disabled="disabled"> ${_('Edit')}</button>
| <button class="btn-link" disabled="disabled"> ${_('Delete')}</button>
%endif
%else:
<button class="btn-link" disabled="disabled"> ${_('Delete')}</button>
<button class="btn-link" disabled="disabled"> ${_('Edit')}</button>
| <button class="btn-link" disabled="disabled"> ${_('Delete')}</button>
%endif
% if outdated_at_ver:
| <a onclick="return Rhodecode.comments.prevOutdatedComment(this);" class="prev-comment"> ${_('Prev')}</a>
| <a onclick="return Rhodecode.comments.nextOutdatedComment(this);" class="next-comment"> ${_('Next')}</a>
| <a onclick="return Rhodecode.comments.prevOutdatedComment(this);" class="tooltip prev-comment" title="${_('Jump to the previous outdated comment')}"> <i class="icon-angle-left"></i> </a>
| <a onclick="return Rhodecode.comments.nextOutdatedComment(this);" class="tooltip next-comment" title="${_('Jump to the next outdated comment')}"> <i class="icon-angle-right"></i></a>
% else:
| <a onclick="return Rhodecode.comments.prevComment(this);" class="prev-comment"> ${_('Prev')}</a>
| <a onclick="return Rhodecode.comments.nextComment(this);" class="next-comment"> ${_('Next')}</a>
| <a onclick="return Rhodecode.comments.prevComment(this);" class="tooltip prev-comment" title="${_('Jump to the previous comment')}"> <i class="icon-angle-left"></i></a>
| <a onclick="return Rhodecode.comments.nextComment(this);" class="tooltip next-comment" title="${_('Jump to the next comment')}"> <i class="icon-angle-right"></i></a>
% endif
</div>

View file

@ -0,0 +1,7 @@
<%namespace name="base" file="/base/base.mako"/>
${c.comment_history.author.email}
${base.gravatar_with_user(c.comment_history.author.email, 16, tooltip=True)}
${h.age_component(c.comment_history.created_on)}
${c.comment_history.text}
${c.comment_history.version}