Vue Comments: Comments ported to Vue + DnD fileupload
* Drag and drop files to comment editor to add a file attachment * Using Vue to render comments Since comments now has attachments we need to update the schemas ./manage.py maintenance replace_pillar_node_type_schemas
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
from pillar.api.node_types import attachments_embedded_schema
|
||||
|
||||
node_type_comment = {
|
||||
'name': 'comment',
|
||||
'description': 'Comments for asset nodes, pages, etc.',
|
||||
@@ -51,7 +53,8 @@ node_type_comment = {
|
||||
}
|
||||
},
|
||||
'confidence': {'type': 'float'},
|
||||
'is_reply': {'type': 'boolean'}
|
||||
'is_reply': {'type': 'boolean'},
|
||||
'attachments': attachments_embedded_schema,
|
||||
},
|
||||
'form_schema': {},
|
||||
'parent': ['asset', 'comment'],
|
||||
|
@@ -6,14 +6,14 @@ import pymongo.errors
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
from flask import current_app, Blueprint, request
|
||||
|
||||
from pillar.api.nodes import eve_hooks
|
||||
from pillar.api.nodes import eve_hooks, comments
|
||||
from pillar.api.utils import str2id, jsonify
|
||||
from pillar.api.utils.authorization import check_permissions, require_login
|
||||
from pillar.web.utils import pretty_date
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
blueprint = Blueprint('nodes_api', __name__)
|
||||
ROLES_FOR_SHARING = {'subscriber', 'demo'}
|
||||
ROLES_FOR_SHARING = ROLES_FOR_COMMENTING ={'subscriber', 'demo'}
|
||||
|
||||
|
||||
@blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
|
||||
@@ -51,6 +51,41 @@ def share_node(node_id):
|
||||
return jsonify(eve_hooks.short_link_info(short_code), status=status)
|
||||
|
||||
|
||||
@blueprint.route('/<string(length=24):node_path>/comments', methods=['GET'])
|
||||
def get_node_comments(node_path: str):
|
||||
node_id = str2id(node_path)
|
||||
return comments.get_node_comments(node_id)
|
||||
|
||||
|
||||
@blueprint.route('/<string(length=24):node_path>/comments', methods=['POST'])
|
||||
@require_login(require_roles=ROLES_FOR_COMMENTING)
|
||||
def post_node_comment(node_path: str):
|
||||
node_id = str2id(node_path)
|
||||
msg = request.json['msg']
|
||||
attachments = request.json.get('attachments', {})
|
||||
return comments.post_node_comment(node_id, msg, attachments)
|
||||
|
||||
|
||||
@blueprint.route('/<string(length=24):node_path>/comments/<string(length=24):comment_path>', methods=['PATCH'])
|
||||
@require_login(require_roles=ROLES_FOR_COMMENTING)
|
||||
def patch_node_comment(node_path: str, comment_path: str):
|
||||
node_id = str2id(node_path)
|
||||
comment_id = str2id(comment_path)
|
||||
msg = request.json['msg']
|
||||
attachments = request.json.get('attachments', {})
|
||||
return comments.patch_node_comment(node_id, comment_id, msg, attachments)
|
||||
|
||||
|
||||
@blueprint.route('/<string(length=24):node_path>/comments/<string(length=24):comment_path>/vote', methods=['POST'])
|
||||
@require_login(require_roles=ROLES_FOR_COMMENTING)
|
||||
def post_node_comment_vote(node_path: str, comment_path: str):
|
||||
node_id = str2id(node_path)
|
||||
comment_id = str2id(comment_path)
|
||||
vote_str = request.json['vote']
|
||||
vote = int(vote_str)
|
||||
return comments.post_node_comment_vote(node_id, comment_id, vote)
|
||||
|
||||
|
||||
@blueprint.route('/tagged/')
|
||||
@blueprint.route('/tagged/<tag>')
|
||||
def tagged(tag=''):
|
||||
|
290
pillar/api/nodes/comments.py
Normal file
290
pillar/api/nodes/comments.py
Normal file
@@ -0,0 +1,290 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import pymongo
|
||||
import typing
|
||||
|
||||
import bson
|
||||
import attr
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
|
||||
import pillar
|
||||
from pillar import current_app, shortcodes
|
||||
from pillar.api.nodes.custom.comment import patch_comment
|
||||
from pillar.api.utils import jsonify, gravatar
|
||||
from pillar.auth import current_user
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True)
|
||||
class UserDO:
|
||||
id: str
|
||||
full_name: str
|
||||
gravatar: str
|
||||
badges_html: str
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True)
|
||||
class CommentPropertiesDO:
|
||||
attachments: typing.Dict
|
||||
rating_positive: int = 0
|
||||
rating_negative: int = 0
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True)
|
||||
class CommentDO:
|
||||
id: bson.ObjectId
|
||||
parent: bson.ObjectId
|
||||
project: bson.ObjectId
|
||||
user: UserDO
|
||||
msg_html: str
|
||||
msg_markdown: str
|
||||
properties: CommentPropertiesDO
|
||||
created: datetime
|
||||
updated: datetime
|
||||
etag: str
|
||||
replies: typing.List['CommentDO'] = []
|
||||
current_user_rating: typing.Optional[bool] = None
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True)
|
||||
class CommentTreeDO:
|
||||
node_id: bson.ObjectId
|
||||
project: bson.ObjectId
|
||||
nbr_of_comments: int = 0
|
||||
comments: typing.List[CommentDO] = []
|
||||
|
||||
|
||||
def _get_markdowned_html(document: dict, field_name: str) -> str:
|
||||
cache_field_name = pillar.markdown.cache_field_name(field_name)
|
||||
html = document.get(cache_field_name)
|
||||
if html is None:
|
||||
markdown_src = document.get(field_name) or ''
|
||||
html = pillar.markdown.markdown(markdown_src)
|
||||
return html
|
||||
|
||||
|
||||
def jsonify_data_object(data_object: attr):
|
||||
return jsonify(
|
||||
attr.asdict(data_object,
|
||||
recurse=True)
|
||||
)
|
||||
|
||||
|
||||
class CommentTreeBuilder:
|
||||
def __init__(self, node_id: bson.ObjectId):
|
||||
self.node_id = node_id
|
||||
self.nbr_of_Comments: int = 0
|
||||
|
||||
def build(self) -> CommentTreeDO:
|
||||
enriched_comments = self.child_comments(self.node_id)
|
||||
project_id = self.get_project_id()
|
||||
return CommentTreeDO(
|
||||
node_id=self.node_id,
|
||||
project=project_id,
|
||||
nbr_of_comments=self.nbr_of_Comments,
|
||||
comments=enriched_comments
|
||||
)
|
||||
|
||||
def child_comments(self, node_id: bson.ObjectId) -> typing.List[CommentDO]:
|
||||
raw_comments = self.mongodb_comments(node_id)
|
||||
return [self.enrich(comment) for comment in raw_comments]
|
||||
|
||||
def enrich(self, mongo_comment: dict) -> CommentDO:
|
||||
self.nbr_of_Comments += 1
|
||||
comment = to_comment_data_object(mongo_comment)
|
||||
comment.replies = self.child_comments(mongo_comment['_id'])
|
||||
return comment
|
||||
|
||||
def get_project_id(self):
|
||||
nodes_coll = current_app.db('nodes')
|
||||
result = nodes_coll.find_one({'_id': self.node_id})
|
||||
return result['project']
|
||||
|
||||
@classmethod
|
||||
def mongodb_comments(cls, node_id: bson.ObjectId) -> typing.Iterator:
|
||||
nodes_coll = current_app.db('nodes')
|
||||
return nodes_coll.aggregate([
|
||||
{'$match': {'node_type': 'comment',
|
||||
'_deleted': {'$ne': True},
|
||||
'properties.status': 'published',
|
||||
'parent': node_id}},
|
||||
{'$lookup': {"from": "users",
|
||||
"localField": "user",
|
||||
"foreignField": "_id",
|
||||
"as": "user"}},
|
||||
{'$unwind': {'path': "$user"}},
|
||||
{'$sort': {'properties.rating_positive': pymongo.DESCENDING,
|
||||
'_created': pymongo.DESCENDING}},
|
||||
])
|
||||
|
||||
|
||||
def get_node_comments(node_id: bson.ObjectId):
|
||||
comments_tree = CommentTreeBuilder(node_id).build()
|
||||
return jsonify_data_object(comments_tree)
|
||||
|
||||
|
||||
def post_node_comment(parent_id: bson.ObjectId, markdown_msg: str, attachments: dict):
|
||||
parent_node = find_node_or_raise(parent_id,
|
||||
'User %s tried to update comment with bad parent_id %s',
|
||||
current_user.objectid,
|
||||
parent_id)
|
||||
|
||||
is_reply = parent_node['node_type'] == 'comment'
|
||||
comment = dict(
|
||||
parent=parent_id,
|
||||
project=parent_node['project'],
|
||||
name='Comment',
|
||||
user=current_user.objectid,
|
||||
node_type='comment',
|
||||
properties=dict(
|
||||
content=markdown_msg,
|
||||
status='published',
|
||||
is_reply=is_reply,
|
||||
confidence=0,
|
||||
rating_positive=0,
|
||||
rating_negative=0,
|
||||
attachments=attachments,
|
||||
)
|
||||
)
|
||||
r, _, _, status = current_app.post_internal('nodes', comment)
|
||||
|
||||
if status != 201:
|
||||
log.warning('Unable to post comment on %s as %s: %s',
|
||||
parent_id, current_user.objectid, r)
|
||||
raise wz_exceptions.InternalServerError('Unable to create comment')
|
||||
|
||||
comment_do = get_comment(parent_id, r['_id'])
|
||||
|
||||
return jsonify_data_object(comment_do), 201
|
||||
|
||||
|
||||
def find_node_or_raise(node_id, *args):
|
||||
nodes_coll = current_app.db('nodes')
|
||||
node_to_comment = nodes_coll.find_one({
|
||||
'_id': node_id,
|
||||
'_deleted': {'$ne': True},
|
||||
})
|
||||
if not node_to_comment:
|
||||
log.warning(args)
|
||||
raise wz_exceptions.UnprocessableEntity()
|
||||
return node_to_comment
|
||||
|
||||
|
||||
def patch_node_comment(parent_id: bson.ObjectId, comment_id: bson.ObjectId, markdown_msg: str, attachments: dict):
|
||||
_, _ = find_parent_and_comment_or_raise(parent_id, comment_id)
|
||||
|
||||
patch = dict(
|
||||
op='edit',
|
||||
content=markdown_msg,
|
||||
attachments=attachments
|
||||
)
|
||||
|
||||
json_result = patch_comment(comment_id, patch)
|
||||
if json_result.json['result'] != 200:
|
||||
raise wz_exceptions.InternalServerError('Failed to update comment')
|
||||
|
||||
comment_do = get_comment(parent_id, comment_id)
|
||||
|
||||
return jsonify_data_object(comment_do), 200
|
||||
|
||||
|
||||
def find_parent_and_comment_or_raise(parent_id, comment_id):
|
||||
parent = find_node_or_raise(parent_id,
|
||||
'User %s tried to update comment with bad parent_id %s',
|
||||
current_user.objectid,
|
||||
parent_id)
|
||||
comment = find_node_or_raise(comment_id,
|
||||
'User %s tried to update comment with bad id %s',
|
||||
current_user.objectid,
|
||||
comment_id)
|
||||
validate_comment_parent_relation(comment, parent)
|
||||
return parent, comment
|
||||
|
||||
|
||||
def validate_comment_parent_relation(comment, parent):
|
||||
if comment['parent'] != parent['_id']:
|
||||
log.warning('User %s tried to update comment with bad parent/comment pair. parent_id: %s comment_id: %s',
|
||||
current_user.objectid,
|
||||
parent['_id'],
|
||||
comment['_id'])
|
||||
raise wz_exceptions.BadRequest()
|
||||
|
||||
|
||||
def get_comment(parent_id: bson.ObjectId, comment_id: bson.ObjectId) -> CommentDO:
|
||||
nodes_coll = current_app.db('nodes')
|
||||
mongo_comment = list(nodes_coll.aggregate([
|
||||
{'$match': {'node_type': 'comment',
|
||||
'_deleted': {'$ne': True},
|
||||
'properties.status': 'published',
|
||||
'parent': parent_id,
|
||||
'_id': comment_id}},
|
||||
{'$lookup': {"from": "users",
|
||||
"localField": "user",
|
||||
"foreignField": "_id",
|
||||
"as": "user"}},
|
||||
{'$unwind': {'path': "$user"}},
|
||||
]))[0]
|
||||
|
||||
return to_comment_data_object(mongo_comment)
|
||||
|
||||
|
||||
def to_comment_data_object(mongo_comment: dict) -> CommentDO:
|
||||
def current_user_rating():
|
||||
if current_user.is_authenticated:
|
||||
for rating in mongo_comment['properties'].get('ratings', ()):
|
||||
if str(rating['user']) != current_user.objectid:
|
||||
continue
|
||||
return rating['is_positive']
|
||||
return None
|
||||
|
||||
user_dict = mongo_comment['user']
|
||||
user = UserDO(
|
||||
id=str(mongo_comment['user']['_id']),
|
||||
full_name=user_dict['full_name'],
|
||||
gravatar=gravatar(user_dict['email']),
|
||||
badges_html=user_dict.get('badges', {}).get('html', '')
|
||||
)
|
||||
html = _get_markdowned_html(mongo_comment['properties'], 'content')
|
||||
html = shortcodes.render_commented(html, context=mongo_comment['properties'])
|
||||
return CommentDO(
|
||||
id=mongo_comment['_id'],
|
||||
parent=mongo_comment['parent'],
|
||||
project=mongo_comment['project'],
|
||||
user=user,
|
||||
msg_html=html,
|
||||
msg_markdown=mongo_comment['properties']['content'],
|
||||
current_user_rating=current_user_rating(),
|
||||
created=mongo_comment['_created'],
|
||||
updated=mongo_comment['_updated'],
|
||||
etag=mongo_comment['_etag'],
|
||||
properties=CommentPropertiesDO(
|
||||
attachments=mongo_comment['properties'].get('attachments', {}),
|
||||
rating_positive=mongo_comment['properties']['rating_positive'],
|
||||
rating_negative=mongo_comment['properties']['rating_negative']
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def post_node_comment_vote(parent_id: bson.ObjectId, comment_id: bson.ObjectId, vote: int):
|
||||
normalized_vote = min(max(vote, -1), 1)
|
||||
_, _ = find_parent_and_comment_or_raise(parent_id, comment_id)
|
||||
|
||||
actions = {
|
||||
1: 'upvote',
|
||||
0: 'revoke',
|
||||
-1: 'downvote',
|
||||
}
|
||||
|
||||
patch = dict(
|
||||
op=actions[normalized_vote]
|
||||
)
|
||||
|
||||
json_result = patch_comment(comment_id, patch)
|
||||
if json_result.json['_status'] != 'OK':
|
||||
raise wz_exceptions.InternalServerError('Failed to vote on comment')
|
||||
|
||||
comment_do = get_comment(parent_id, comment_id)
|
||||
return jsonify_data_object(comment_do), 200
|
@@ -5,7 +5,7 @@ import logging
|
||||
from flask import current_app
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
|
||||
from pillar.api.utils import authorization, authentication, jsonify
|
||||
from pillar.api.utils import authorization, authentication, jsonify, remove_private_keys
|
||||
|
||||
from . import register_patch_handler
|
||||
|
||||
@@ -135,10 +135,7 @@ def edit_comment(user_id, node_id, patch):
|
||||
# we can pass this stuff to Eve's patch_internal; that way the validation &
|
||||
# authorisation system has enough info to work.
|
||||
nodes_coll = current_app.data.driver.db['nodes']
|
||||
projection = {'user': 1,
|
||||
'project': 1,
|
||||
'node_type': 1}
|
||||
node = nodes_coll.find_one(node_id, projection=projection)
|
||||
node = nodes_coll.find_one(node_id)
|
||||
if node is None:
|
||||
log.warning('User %s wanted to patch non-existing node %s' % (user_id, node_id))
|
||||
raise wz_exceptions.NotFound('Node %s not found' % node_id)
|
||||
@@ -146,14 +143,14 @@ def edit_comment(user_id, node_id, patch):
|
||||
if node['user'] != user_id and not authorization.user_has_role('admin'):
|
||||
raise wz_exceptions.Forbidden('You can only edit your own comments.')
|
||||
|
||||
# Use Eve to PATCH this node, as that also updates the etag.
|
||||
r, _, _, status = current_app.patch_internal('nodes',
|
||||
{'properties.content': patch['content'],
|
||||
'project': node['project'],
|
||||
'user': node['user'],
|
||||
'node_type': node['node_type']},
|
||||
concurrency_check=False,
|
||||
_id=node_id)
|
||||
node = remove_private_keys(node)
|
||||
node['properties']['content'] = patch['content']
|
||||
node['properties']['attachments'] = patch.get('attachments', {})
|
||||
# Use Eve to PUT this node, as that also updates the etag and we want to replace attachments.
|
||||
r, _, _, status = current_app.put_internal('nodes',
|
||||
node,
|
||||
concurrency_check=False,
|
||||
_id=node_id)
|
||||
if status != 200:
|
||||
log.error('Error %i editing comment %s for user %s: %s',
|
||||
status, node_id, user_id, r)
|
||||
|
Reference in New Issue
Block a user