##// END OF EJS Templates
comments: added support for adding comment attachments using the artifacts logic....
bart -
r3973:d9b26077 default
parent child Browse files
Show More
@@ -26,7 +26,8 b' import hashlib'
26 from rhodecode.lib.ext_json import json
26 from rhodecode.lib.ext_json import json
27 from rhodecode.apps.file_store import utils
27 from rhodecode.apps.file_store import utils
28 from rhodecode.apps.file_store.extensions import resolve_extensions
28 from rhodecode.apps.file_store.extensions import resolve_extensions
29 from rhodecode.apps.file_store.exceptions import FileNotAllowedException
29 from rhodecode.apps.file_store.exceptions import (
30 FileNotAllowedException, FileOverSizeException)
30
31
31 METADATA_VER = 'v1'
32 METADATA_VER = 'v1'
32
33
@@ -157,7 +158,7 b' class LocalFileStorage(object):'
157 return ext in [normalize_ext(x) for x in extensions]
158 return ext in [normalize_ext(x) for x in extensions]
158
159
159 def save_file(self, file_obj, filename, directory=None, extensions=None,
160 def save_file(self, file_obj, filename, directory=None, extensions=None,
160 extra_metadata=None, **kwargs):
161 extra_metadata=None, max_filesize=None, **kwargs):
161 """
162 """
162 Saves a file object to the uploads location.
163 Saves a file object to the uploads location.
163 Returns the resolved filename, i.e. the directory +
164 Returns the resolved filename, i.e. the directory +
@@ -167,7 +168,9 b' class LocalFileStorage(object):'
167 :param filename: original filename
168 :param filename: original filename
168 :param directory: relative path of sub-directory
169 :param directory: relative path of sub-directory
169 :param extensions: iterable of allowed extensions, if not default
170 :param extensions: iterable of allowed extensions, if not default
171 :param max_filesize: maximum size of file that should be allowed
170 :param extra_metadata: extra JSON metadata to store next to the file with .meta suffix
172 :param extra_metadata: extra JSON metadata to store next to the file with .meta suffix
173
171 """
174 """
172
175
173 extensions = extensions or self.extensions
176 extensions = extensions or self.extensions
@@ -199,6 +202,12 b' class LocalFileStorage(object):'
199 metadata = extra_metadata
202 metadata = extra_metadata
200
203
201 size = os.stat(path).st_size
204 size = os.stat(path).st_size
205
206 if max_filesize and size > max_filesize:
207 # free up the copied file, and raise exc
208 os.remove(path)
209 raise FileOverSizeException()
210
202 file_hash = self.calculate_path_hash(path)
211 file_hash = self.calculate_path_hash(path)
203
212
204 metadata.update(
213 metadata.update(
@@ -79,6 +79,10 b' def includeme(config):'
79 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/preview', repo_route=True)
79 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/preview', repo_route=True)
80
80
81 config.add_route(
81 config.add_route(
82 name='repo_commit_comment_attachment_upload',
83 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/attachment_upload', repo_route=True)
84
85 config.add_route(
82 name='repo_commit_comment_delete',
86 name='repo_commit_comment_delete',
83 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_id}/delete', repo_route=True)
87 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_id}/delete', repo_route=True)
84
88
@@ -28,6 +28,8 b' from pyramid.renderers import render'
28 from pyramid.response import Response
28 from pyramid.response import Response
29
29
30 from rhodecode.apps._base import RepoAppView
30 from rhodecode.apps._base import RepoAppView
31 from rhodecode.apps.file_store import utils as store_utils
32 from rhodecode.apps.file_store.exceptions import FileNotAllowedException, FileOverSizeException
31
33
32 from rhodecode.lib import diffs, codeblocks
34 from rhodecode.lib import diffs, codeblocks
33 from rhodecode.lib.auth import (
35 from rhodecode.lib.auth import (
@@ -43,7 +45,7 b' from rhodecode.lib.utils2 import safe_un'
43 from rhodecode.lib.vcs.backends.base import EmptyCommit
45 from rhodecode.lib.vcs.backends.base import EmptyCommit
44 from rhodecode.lib.vcs.exceptions import (
46 from rhodecode.lib.vcs.exceptions import (
45 RepositoryError, CommitDoesNotExistError)
47 RepositoryError, CommitDoesNotExistError)
46 from rhodecode.model.db import ChangesetComment, ChangesetStatus
48 from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore
47 from rhodecode.model.changeset_status import ChangesetStatusModel
49 from rhodecode.model.changeset_status import ChangesetStatusModel
48 from rhodecode.model.comment import CommentsModel
50 from rhodecode.model.comment import CommentsModel
49 from rhodecode.model.meta import Session
51 from rhodecode.model.meta import Session
@@ -423,6 +425,101 b' class RepoCommitsView(RepoAppView):'
423 'repository.read', 'repository.write', 'repository.admin')
425 'repository.read', 'repository.write', 'repository.admin')
424 @CSRFRequired()
426 @CSRFRequired()
425 @view_config(
427 @view_config(
428 route_name='repo_commit_comment_attachment_upload', request_method='POST',
429 renderer='json_ext', xhr=True)
430 def repo_commit_comment_attachment_upload(self):
431 c = self.load_default_context()
432 upload_key = 'attachment'
433
434 file_obj = self.request.POST.get(upload_key)
435
436 if file_obj is None:
437 self.request.response.status = 400
438 return {'store_fid': None,
439 'access_path': None,
440 'error': '{} data field is missing'.format(upload_key)}
441
442 if not hasattr(file_obj, 'filename'):
443 self.request.response.status = 400
444 return {'store_fid': None,
445 'access_path': None,
446 'error': 'filename cannot be read from the data field'}
447
448 filename = file_obj.filename
449 file_display_name = filename
450
451 metadata = {
452 'user_uploaded': {'username': self._rhodecode_user.username,
453 'user_id': self._rhodecode_user.user_id,
454 'ip': self._rhodecode_user.ip_addr}}
455
456 # TODO(marcink): allow .ini configuration for allowed_extensions, and file-size
457 allowed_extensions = [
458 'gif', '.jpeg', '.jpg', '.png', '.docx', '.gz', '.log', '.pdf',
459 '.pptx', '.txt', '.xlsx', '.zip']
460 max_file_size = 10 * 1024 * 1024 # 10MB, also validated via dropzone.js
461
462 try:
463 storage = store_utils.get_file_storage(self.request.registry.settings)
464 store_uid, metadata = storage.save_file(
465 file_obj.file, filename, extra_metadata=metadata,
466 extensions=allowed_extensions, max_filesize=max_file_size)
467 except FileNotAllowedException:
468 self.request.response.status = 400
469 permitted_extensions = ', '.join(allowed_extensions)
470 error_msg = 'File `{}` is not allowed. ' \
471 'Only following extensions are permitted: {}'.format(
472 filename, permitted_extensions)
473 return {'store_fid': None,
474 'access_path': None,
475 'error': error_msg}
476 except FileOverSizeException:
477 self.request.response.status = 400
478 limit_mb = h.format_byte_size_binary(max_file_size)
479 return {'store_fid': None,
480 'access_path': None,
481 'error': 'File {} is exceeding allowed limit of {}.'.format(
482 filename, limit_mb)}
483
484 try:
485 entry = FileStore.create(
486 file_uid=store_uid, filename=metadata["filename"],
487 file_hash=metadata["sha256"], file_size=metadata["size"],
488 file_display_name=file_display_name,
489 file_description=u'comment attachment `{}`'.format(safe_unicode(filename)),
490 hidden=True, check_acl=True, user_id=self._rhodecode_user.user_id,
491 scope_repo_id=self.db_repo.repo_id
492 )
493 Session().add(entry)
494 Session().commit()
495 log.debug('Stored upload in DB as %s', entry)
496 except Exception:
497 log.exception('Failed to store file %s', filename)
498 self.request.response.status = 400
499 return {'store_fid': None,
500 'access_path': None,
501 'error': 'File {} failed to store in DB.'.format(filename)}
502
503 Session().commit()
504
505 return {
506 'store_fid': store_uid,
507 'access_path': h.route_path(
508 'download_file', fid=store_uid),
509 'fqn_access_path': h.route_url(
510 'download_file', fid=store_uid),
511 'repo_access_path': h.route_path(
512 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
513 'repo_fqn_access_path': h.route_url(
514 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
515 }
516
517 @LoginRequired()
518 @NotAnonymous()
519 @HasRepoPermissionAnyDecorator(
520 'repository.read', 'repository.write', 'repository.admin')
521 @CSRFRequired()
522 @view_config(
426 route_name='repo_commit_comment_delete', request_method='POST',
523 route_name='repo_commit_comment_delete', request_method='POST',
427 renderer='json_ext')
524 renderer='json_ext')
428 def repo_commit_comment_delete(self):
525 def repo_commit_comment_delete(self):
@@ -5115,7 +5115,7 b' class FileStore(Base, BaseModel):'
5115 # if repo/repo_group reference is set, check for permissions
5115 # if repo/repo_group reference is set, check for permissions
5116 check_acl = Column('check_acl', Boolean(), nullable=False, default=True)
5116 check_acl = Column('check_acl', Boolean(), nullable=False, default=True)
5117
5117
5118 # hidden defines an attachement that should be hidden from showing in artifact listing
5118 # hidden defines an attachment that should be hidden from showing in artifact listing
5119 hidden = Column('hidden', Boolean(), nullable=False, default=False)
5119 hidden = Column('hidden', Boolean(), nullable=False, default=False)
5120
5120
5121 user_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False)
5121 user_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False)
@@ -5147,8 +5147,8 b' class FileStore(Base, BaseModel):'
5147
5147
5148 @classmethod
5148 @classmethod
5149 def create(cls, file_uid, filename, file_hash, file_size, file_display_name='',
5149 def create(cls, file_uid, filename, file_hash, file_size, file_display_name='',
5150 file_description='', enabled=True, check_acl=True, user_id=None,
5150 file_description='', enabled=True, hidden=False, check_acl=True,
5151 scope_user_id=None, scope_repo_id=None, scope_repo_group_id=None):
5151 user_id=None, scope_user_id=None, scope_repo_id=None, scope_repo_group_id=None):
5152
5152
5153 store_entry = FileStore()
5153 store_entry = FileStore()
5154 store_entry.file_uid = file_uid
5154 store_entry.file_uid = file_uid
@@ -5160,11 +5160,13 b' class FileStore(Base, BaseModel):'
5160
5160
5161 store_entry.check_acl = check_acl
5161 store_entry.check_acl = check_acl
5162 store_entry.enabled = enabled
5162 store_entry.enabled = enabled
5163 store_entry.hidden = hidden
5163
5164
5164 store_entry.user_id = user_id
5165 store_entry.user_id = user_id
5165 store_entry.scope_user_id = scope_user_id
5166 store_entry.scope_user_id = scope_user_id
5166 store_entry.scope_repo_id = scope_repo_id
5167 store_entry.scope_repo_id = scope_repo_id
5167 store_entry.scope_repo_group_id = scope_repo_group_id
5168 store_entry.scope_repo_group_id = scope_repo_group_id
5169
5168 return store_entry
5170 return store_entry
5169
5171
5170 @classmethod
5172 @classmethod
@@ -539,13 +539,40 b' form.comment-form {'
539 }
539 }
540
540
541 .comment-area-footer {
541 .comment-area-footer {
542 display: flex;
542 min-height: 30px;
543 }
543 }
544
544
545 .comment-footer .toolbar {
545 .comment-footer .toolbar {
546
546
547 }
547 }
548
548
549 .comment-attachment-uploader {
550 border: 1px dashed white;
551 border-radius: @border-radius;
552 margin-top: -10px;
553
554 &.dz-drag-hover {
555 border-color: @grey3;
556 }
557
558 .dz-error-message {
559 padding-top: 0;
560 }
561 }
562
563 .comment-attachment-text {
564 clear: both;
565 font-size: 11px;
566 color: #8F8F8F;
567 width: 100%;
568 .pick-attachment {
569 color: #8F8F8F;
570 }
571 .pick-attachment:hover {
572 color: @rcblue;
573 }
574 }
575
549 .nav-links {
576 .nav-links {
550 padding: 0;
577 padding: 0;
551 margin: 0;
578 margin: 0;
@@ -579,7 +606,6 b' form.comment-form {'
579
606
580 .toolbar-text {
607 .toolbar-text {
581 float: left;
608 float: left;
582 margin: -5px 0px 0px 0px;
583 font-size: 12px;
609 font-size: 12px;
584 }
610 }
585
611
@@ -204,6 +204,7 b' div.markdown-block img {'
204 border-style: none;
204 border-style: none;
205 background-color: #fff;
205 background-color: #fff;
206 padding-right: 20px;
206 padding-right: 20px;
207 max-width: 100%;
207 }
208 }
208
209
209
210
@@ -118,6 +118,10 b' table.dataTable {'
118 }
118 }
119 }
119 }
120
120
121 &.td-sha {
122 white-space: nowrap;
123 }
124
121 &.td-graphbox {
125 &.td-graphbox {
122 width: 100px;
126 width: 100px;
123 max-width: 100px;
127 max-width: 100px;
@@ -174,6 +174,7 b' function registerRCRoutes() {'
174 pyroutes.register('repo_commit_data', '/%(repo_name)s/changeset-data/%(commit_id)s', ['repo_name', 'commit_id']);
174 pyroutes.register('repo_commit_data', '/%(repo_name)s/changeset-data/%(commit_id)s', ['repo_name', 'commit_id']);
175 pyroutes.register('repo_commit_comment_create', '/%(repo_name)s/changeset/%(commit_id)s/comment/create', ['repo_name', 'commit_id']);
175 pyroutes.register('repo_commit_comment_create', '/%(repo_name)s/changeset/%(commit_id)s/comment/create', ['repo_name', 'commit_id']);
176 pyroutes.register('repo_commit_comment_preview', '/%(repo_name)s/changeset/%(commit_id)s/comment/preview', ['repo_name', 'commit_id']);
176 pyroutes.register('repo_commit_comment_preview', '/%(repo_name)s/changeset/%(commit_id)s/comment/preview', ['repo_name', 'commit_id']);
177 pyroutes.register('repo_commit_comment_attachment_upload', '/%(repo_name)s/changeset/%(commit_id)s/comment/attachment_upload', ['repo_name', 'commit_id']);
177 pyroutes.register('repo_commit_comment_delete', '/%(repo_name)s/changeset/%(commit_id)s/comment/%(comment_id)s/delete', ['repo_name', 'commit_id', 'comment_id']);
178 pyroutes.register('repo_commit_comment_delete', '/%(repo_name)s/changeset/%(commit_id)s/comment/%(comment_id)s/delete', ['repo_name', 'commit_id', 'comment_id']);
178 pyroutes.register('repo_commit_raw_deprecated', '/%(repo_name)s/raw-changeset/%(commit_id)s', ['repo_name', 'commit_id']);
179 pyroutes.register('repo_commit_raw_deprecated', '/%(repo_name)s/raw-changeset/%(commit_id)s', ['repo_name', 'commit_id']);
179 pyroutes.register('repo_archivefile', '/%(repo_name)s/archive/%(fname)s', ['repo_name', 'fname']);
180 pyroutes.register('repo_archivefile', '/%(repo_name)s/archive/%(fname)s', ['repo_name', 'fname']);
@@ -654,6 +654,95 b' var CommentsController = function() {'
654 }, 100);
654 }, 100);
655 }
655 }
656
656
657 // add dropzone support
658 var insertAttachmentText = function (cm, attachmentName, attachmentStoreUrl, isRendered) {
659 var renderer = templateContext.visual.default_renderer;
660 if (renderer == 'rst') {
661 var attachmentUrl = '`#{0} <{1}>`_'.format(attachmentName, attachmentStoreUrl);
662 if (isRendered){
663 attachmentUrl = '\n.. image:: {0}'.format(attachmentStoreUrl);
664 }
665 } else if (renderer == 'markdown') {
666 var attachmentUrl = '[{0}]({1})'.format(attachmentName, attachmentStoreUrl);
667 if (isRendered){
668 attachmentUrl = '!' + attachmentUrl;
669 }
670 } else {
671 var attachmentUrl = '{}'.format(attachmentStoreUrl);
672 }
673 cm.replaceRange(attachmentUrl+'\n', CodeMirror.Pos(cm.lastLine()));
674
675 return false;
676 };
677
678 //see: https://www.dropzonejs.com/#configuration
679 var storeUrl = pyroutes.url('repo_commit_comment_attachment_upload',
680 {'repo_name': templateContext.repo_name,
681 'commit_id': templateContext.commit_data.commit_id})
682
683 var previewTmpl = $(formElement).find('.comment-attachment-uploader-template').get(0).innerHTML;
684 var selectLink = $(formElement).find('.pick-attachment').get(0);
685 $(formElement).find('.comment-attachment-uploader').dropzone({
686 url: storeUrl,
687 headers: {"X-CSRF-Token": CSRF_TOKEN},
688 paramName: function () {
689 return "attachment"
690 }, // The name that will be used to transfer the file
691 clickable: selectLink,
692 parallelUploads: 1,
693 maxFiles: 10,
694 maxFilesize: templateContext.attachment_store.max_file_size_mb,
695 uploadMultiple: false,
696 autoProcessQueue: true, // if false queue will not be processed automatically.
697 createImageThumbnails: false,
698 previewTemplate: previewTmpl,
699
700 accept: function (file, done) {
701 done();
702 },
703 init: function () {
704
705 this.on("sending", function (file, xhr, formData) {
706 $(formElement).find('.comment-attachment-uploader').find('.dropzone-text').hide();
707 $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').show();
708 });
709
710 this.on("success", function (file, response) {
711 $(formElement).find('.comment-attachment-uploader').find('.dropzone-text').show();
712 $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').hide();
713
714 var isRendered = false;
715 var ext = file.name.split('.').pop();
716 var imageExts = templateContext.attachment_store.image_ext;
717 if (imageExts.indexOf(ext) !== -1){
718 isRendered = true;
719 }
720
721 insertAttachmentText(cm, file.name, response.repo_fqn_access_path, isRendered)
722 });
723
724 this.on("error", function (file, errorMessage, xhr) {
725 $(formElement).find('.comment-attachment-uploader').find('.dropzone-upload').hide();
726
727 var error = null;
728
729 if (xhr !== undefined){
730 var httpStatus = xhr.status + " " + xhr.statusText;
731 if (xhr.status >= 500) {
732 error = httpStatus;
733 }
734 }
735
736 if (error === null) {
737 error = errorMessage.error || errorMessage || httpStatus;
738 }
739 $(file.previewElement).find('.dz-error-message').html('ERROR: {0}'.format(error));
740
741 });
742 }
743 });
744
745
657 return commentForm;
746 return commentForm;
658 };
747 };
659
748
@@ -34,6 +34,11 b" c.template_context['search_context'] = {"
34 'repo_view_type': c.template_context.get('repo_view_type'),
34 'repo_view_type': c.template_context.get('repo_view_type'),
35 }
35 }
36
36
37 c.template_context['attachment_store'] = {
38 'max_file_size_mb': 10,
39 'image_ext': ["png", "jpg", "gif", "jpeg"]
40 }
41
37 %>
42 %>
38 <html xmlns="http://www.w3.org/1999/xhtml">
43 <html xmlns="http://www.w3.org/1999/xhtml">
39 <head>
44 <head>
@@ -257,7 +257,6 b''
257 });
257 });
258 % endif
258 % endif
259
259
260
261 </script>
260 </script>
262 % else:
261 % else:
263 ## form state when not logged in
262 ## form state when not logged in
@@ -309,8 +308,8 b''
309
308
310
309
311 <%def name="comment_form(form_type, form_id='', lineno_id='{1}', review_statuses=None, form_extras=None)">
310 <%def name="comment_form(form_type, form_id='', lineno_id='{1}', review_statuses=None, form_extras=None)">
311
312 ## comment injected based on assumption that user is logged in
312 ## comment injected based on assumption that user is logged in
313
314 <form ${'id="{}"'.format(form_id) if form_id else '' |n} action="#" method="GET">
313 <form ${'id="{}"'.format(form_id) if form_id else '' |n} action="#" method="GET">
315
314
316 <div class="comment-area">
315 <div class="comment-area">
@@ -341,7 +340,7 b''
341 </div>
340 </div>
342 </div>
341 </div>
343
342
344 <div class="comment-area-footer">
343 <div class="comment-area-footer comment-attachment-uploader">
345 <div class="toolbar">
344 <div class="toolbar">
346 <div class="toolbar-text">
345 <div class="toolbar-text">
347 ${(_('Comments parsed using %s syntax with %s, and %s actions support.') % (
346 ${(_('Comments parsed using %s syntax with %s, and %s actions support.') % (
@@ -351,6 +350,23 b''
351 )
350 )
352 )|n}
351 )|n}
353 </div>
352 </div>
353
354 <div class="comment-attachment-text">
355 <div class="dropzone-text">
356 ${_("Drag'n Drop files here or")} <span class="link pick-attachment">${_('Choose your files')}</span>.<br>
357 </div>
358 <div class="dropzone-upload" style="display:none">
359 <i class="icon-spin animate-spin"></i> ${_('uploading...')}
360 </div>
361 </div>
362
363 ## comments dropzone template, empty on purpose
364 <div style="display: none" class="comment-attachment-uploader-template">
365 <div class="dz-file-preview" style="margin: 0">
366 <div class="dz-error-message"></div>
367 </div>
368 </div>
369
354 </div>
370 </div>
355 </div>
371 </div>
356 </div>
372 </div>
General Comments 0
You need to be logged in to leave comments. Login now