diff --git a/pillar/api/eve_settings.py b/pillar/api/eve_settings.py index 82d3b441..3994130d 100644 --- a/pillar/api/eve_settings.py +++ b/pillar/api/eve_settings.py @@ -579,6 +579,8 @@ projects_schema = { 'picture_square': _file_embedded_schema, # Header 'picture_header': _file_embedded_schema, + # Picture with a 16:9 aspect ratio (for Open Graph) + 'picture_16_9': _file_embedded_schema, 'header_node': dict( nullable=True, **_node_embedded_schema diff --git a/pillar/api/nodes/comments.py b/pillar/api/nodes/comments.py index 25b9978a..66a70a0d 100644 --- a/pillar/api/nodes/comments.py +++ b/pillar/api/nodes/comments.py @@ -149,6 +149,12 @@ def post_node_comment(parent_id: bson.ObjectId, markdown_msg: str, attachments: rating_positive=0, rating_negative=0, attachments=attachments, + ), + permissions=dict( + users=[dict( + user=current_user.objectid, + methods=['PUT']) + ] ) ) r, _, _, status = current_app.post_internal('nodes', comment) diff --git a/pillar/api/nodes/eve_hooks.py b/pillar/api/nodes/eve_hooks.py index 891f4a11..6bff8269 100644 --- a/pillar/api/nodes/eve_hooks.py +++ b/pillar/api/nodes/eve_hooks.py @@ -7,7 +7,6 @@ from bson import ObjectId from werkzeug import exceptions as wz_exceptions from pillar import current_app -import pillar.markdown from pillar.api.activities import activity_subscribe, activity_object_add from pillar.api.file_storage_backends.gcs import update_file_name from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES @@ -122,6 +121,7 @@ def before_inserting_nodes(items): # Default the 'user' property to the current user. item.setdefault('user', current_user.user_id) + def get_comment_verb_and_context_object_id(comment): nodes_collection = current_app.data.driver.db['nodes'] verb = 'commented' @@ -152,7 +152,6 @@ def after_inserting_nodes(items): # Subscribe to the parent of the parent comment (post or group) activity_subscribe(item['user'], 'node', context_object_id) - if context_object_id and item['node_type'] in PILLAR_NAMED_NODE_TYPES: # * Skip activity for first level items (since the context is not a # node, but a project). diff --git a/pillar/tests/common_test_data.py b/pillar/tests/common_test_data.py index fc05cdf7..cd53dfc2 100644 --- a/pillar/tests/common_test_data.py +++ b/pillar/tests/common_test_data.py @@ -73,9 +73,9 @@ EXAMPLE_PROJECT = { 'nodes_featured': [], 'nodes_latest': [], 'permissions': {'groups': [{'group': EXAMPLE_ADMIN_GROUP_ID, - 'methods': ['GET', 'POST', 'PUT', 'DELETE']}], - 'users': [], - 'world': ['GET']}, + 'methods': ['GET', 'POST', 'PUT', 'DELETE']}], + 'users': [], + 'world': ['GET']}, 'picture_header': ObjectId('5673f260c379cf0007b31bc4'), 'picture_square': ObjectId('5673f256c379cf0007b31bc3'), 'status': 'published', diff --git a/pillar/web/projects/forms.py b/pillar/web/projects/forms.py index e67aef08..bfd9830f 100644 --- a/pillar/web/projects/forms.py +++ b/pillar/web/projects/forms.py @@ -30,6 +30,7 @@ class ProjectForm(FlaskForm): ('deleted', 'Deleted')]) picture_header = FileSelectField('Picture header', file_format='image') picture_square = FileSelectField('Picture square', file_format='image') + picture_16_9 = FileSelectField('Picture 16:9', file_format='image') def validate(self): rv = FlaskForm.validate(self) diff --git a/pillar/web/projects/routes.py b/pillar/web/projects/routes.py index 737df72e..3fc33387 100644 --- a/pillar/web/projects/routes.py +++ b/pillar/web/projects/routes.py @@ -349,8 +349,7 @@ def project_navigation_links(project: typing.Type[Project], api) -> list: def render_project(project, api, extra_context=None, template_name=None): - project.picture_square = utils.get_file(project.picture_square, api=api) - project.picture_header = utils.get_file(project.picture_header, api=api) + utils.attach_project_pictures(project, api) def load_latest(list_of_ids, node_type=None): """Loads a list of IDs in reversed order.""" @@ -424,7 +423,7 @@ def render_project(project, api, extra_context=None, template_name=None): node=None, show_node=False, show_project=True, - og_picture=project.picture_header, + og_picture=project.picture_16_9, activity_stream=activity_stream, navigation_links=navigation_links, extension_sidebar_links=extension_sidebar_links, @@ -492,9 +491,9 @@ def view_node(project_url, node_id): extension_sidebar_links = '' og_picture = node.picture = utils.get_file(node.picture, api=api) if project: + utils.attach_project_pictures(project, api) if not node.picture: - og_picture = utils.get_file(project.picture_header, api=api) - project.picture_square = utils.get_file(project.picture_square, api=api) + og_picture = project.picture_16_9 navigation_links = project_navigation_links(project, api) extension_sidebar_links = current_app.extension_sidebar_links(project) @@ -541,8 +540,7 @@ def search(project_url): """Search into a project""" api = system_util.pillar_api() project = find_project_or_404(project_url, api=api) - project.picture_square = utils.get_file(project.picture_square, api=api) - project.picture_header = utils.get_file(project.picture_header, api=api) + utils.attach_project_pictures(project, api) return render_template('nodes/search.html', project=project, @@ -583,6 +581,8 @@ def edit(project_url): project.picture_square = form.picture_square.data if form.picture_header.data: project.picture_header = form.picture_header.data + if form.picture_16_9.data: + project.picture_16_9 = form.picture_16_9.data # Update world permissions from is_private checkbox if form.is_private.data: @@ -598,6 +598,8 @@ def edit(project_url): form.picture_square.data = project.picture_square._id if project.picture_header: form.picture_header.data = project.picture_header._id + if project.picture_16_9: + form.picture_16_9.data = project.picture_16_9._id # List of fields from the form that should be hidden to regular users if current_user.has_role('admin'): diff --git a/pillar/web/utils/__init__.py b/pillar/web/utils/__init__.py index 3b80c1ef..12ba57c1 100644 --- a/pillar/web/utils/__init__.py +++ b/pillar/web/utils/__init__.py @@ -45,6 +45,7 @@ def attach_project_pictures(project, api): project.picture_square = get_file(project.picture_square, api=api) project.picture_header = get_file(project.picture_header, api=api) + project.picture_16_9 = get_file(project.picture_16_9, api=api) def mass_attach_project_pictures(projects: typing.Iterable[pillarsdk.Project], *, diff --git a/requirements.txt b/requirements.txt index 195ba6ef..f65a0972 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,7 +52,7 @@ html5lib==1.0.1 idna==2.5 ipaddress==1.0.22 itsdangerous==0.24 -Jinja2==2.10 +Jinja2==2.10.1 kombu==4.2.1 oauth2client==4.1.2 oauthlib==2.1.0 diff --git a/src/styles/_utils.sass b/src/styles/_utils.sass index 2615cdb7..19863873 100644 --- a/src/styles/_utils.sass +++ b/src/styles/_utils.sass @@ -689,6 +689,12 @@ .pointer-events-none pointer-events: none +.column-count-2 + column-count: 2 + +.column-count-3 + column-count: 3 + // Bootstrap has .img-fluid, a class to limit the width of an image to 100%. // .imgs-fluid below is to be applied on a parent container when we can't add // classes to the images themselves. e.g. the blog. diff --git a/src/styles/blog.sass b/src/styles/blog.sass index 748591a2..a976c3ce 100644 --- a/src/styles/blog.sass +++ b/src/styles/blog.sass @@ -33,6 +33,9 @@ @import "../../node_modules/bootstrap/scss/utilities" // Pillar components. +$pillar-font-path: "../../../../static/pillar/assets/font" +@import "../../../pillar/src/styles/font-pillar" + @import "apps_base" @import "components/base" diff --git a/src/styles/components/_card.sass b/src/styles/components/_card.sass index 4cc9631b..cfaf23e7 100644 --- a/src/styles/components/_card.sass +++ b/src/styles/components/_card.sass @@ -155,14 +155,14 @@ $card-progress-height: 5px /* Tiny label for cards. e.g. 'WATCHED' on videos. */ .card-label - background-color: rgba($black, .5) - border-radius: 3px - color: $white - display: block + @extend .font-weight-bold + @extend .position-absolute + @extend .rounded + @extend .text-white + @extend .bg-dark + bottom: $card-progress-height + 3px // enough to be above the progress-bar font-size: $font-size-xxs left: 5px - bottom: $card-progress-height + 3px // enough to be above the progress-bar - position: absolute padding: 1px 5px z-index: 1 diff --git a/src/styles/components/_timeline.sass b/src/styles/components/_timeline.sass index adccb092..0a5c8b0c 100644 --- a/src/styles/components/_timeline.sass +++ b/src/styles/components/_timeline.sass @@ -15,10 +15,9 @@ &-title // .group-title @extend .border-bottom @extend .bg-white - @extend .text-uppercase - @extend .font-weight-bold + a - color: $color-text + color: $color-text-dark-hint .node-details-description font: diff --git a/src/styles/theatre.sass b/src/styles/theatre.sass index ef3effab..6132fb26 100644 --- a/src/styles/theatre.sass +++ b/src/styles/theatre.sass @@ -24,9 +24,9 @@ @import "../../node_modules/bootstrap/scss/utilities" - // Pillar components. -@import "font-pillar" +$pillar-font-path: "../../../../static/pillar/assets/font" +@import "../../../pillar/src/styles/font-pillar" @import "apps_base" @import "components/navbar" diff --git a/src/templates/_macros/_asset_list_item.pug b/src/templates/_macros/_asset_list_item.pug index 0f8df617..c9510e55 100644 --- a/src/templates/_macros/_asset_list_item.pug +++ b/src/templates/_macros/_asset_list_item.pug @@ -49,16 +49,16 @@ a.card.asset.card-image-fade.mb-2( ul.card-text.list-unstyled.d-flex.text-black-50.mt-auto.mb-0.text-truncate | {% if node_type %} - li.pr-2.font-weight-bold {{ node_type | undertitle }} + li.item-type.pr-2.font-weight-bold {{ node_type | undertitle }} | {% endif %} | {% if asset.project.name %} - li.pr-2.text-truncate {{ asset.project.name }} + li.item-name.pr-2.text-truncate {{ asset.project.name }} | {% endif %} | {% if asset.user.full_name %} - li.pr-2.text-truncate {{ asset.user.full_name }} + li.item-full_name.pr-2.text-truncate {{ asset.user.full_name }} | {% endif %} | {% if asset._created %} - li.text-truncate {{ asset._created | pretty_date }} + li.item-date.text-truncate {{ asset._created | pretty_date }} | {% endif %} | {% endmacro %} diff --git a/tests/test_api/test_comments.py b/tests/test_api/test_comments.py index efd71897..4679c5e1 100644 --- a/tests/test_api/test_comments.py +++ b/tests/test_api/test_comments.py @@ -70,6 +70,22 @@ class CommentEditTest(AbstractPillarTest): self.user_uid = self.create_user(24 * 'b', groups=[ctd.EXAMPLE_ADMIN_GROUP_ID], token='user-token') + self.other_user_uid = self.create_user(24 * 'c',token='other-user-token') + + # Add world POST permission to comments for the project + # This allows any user to post a comment + for node_type in self.project['node_types']: + if node_type['name'] != 'comment': + continue + node_type['permissions'] = {'world': ['POST']} + + with self.app.app_context(): + proj_coll = self.app.db('projects') + proj_coll.update( + {'_id': self.pid}, + {'$set': { + 'node_types': self.project['node_types'], + }}) def test_edit_comment(self): # Create the comment @@ -86,7 +102,50 @@ class CommentEditTest(AbstractPillarTest): payload = json.loads(resp.data) comment_id = payload['id'] - comment_url = flask.url_for('nodes_api.patch_node_comment', node_path=str(self.node_id), comment_path=comment_id) + comment_url = flask.url_for('nodes_api.patch_node_comment', node_path=str(self.node_id), + comment_path=comment_id) + # Edit the comment + resp = self.patch( + comment_url, + json={ + 'msg': 'Edited comment', + }, + expected_status=200, + ) + + self.assertEqual(200, resp.status_code) + payload = json.loads(resp.data) + self.assertEqual('Edited comment', payload['msg_markdown']) + self.assertEqual('

Edited comment

\n', payload['msg_html']) + + def test_edit_comment_non_admin(self): + """Verify that a comment can be edited by a regular user.""" + # Create the comment + with self.login_as(self.other_user_uid): + comment_url = flask.url_for('nodes_api.post_node_comment', node_path=str(self.node_id)) + resp = self.post( + comment_url, + json={ + 'msg': 'There is no place like [home](https://cloud.blender.org/)', + }, + expected_status=201, + ) + + payload = json.loads(resp.data) + + # Check that the comment has edit (PUT) permission for the current user + with self.app.app_context(): + nodes_coll = self.app.db('nodes') + db_node = nodes_coll.find_one(ObjectId(payload['id'])) + expected_permissions = {'users': [{ + 'user': self.other_user_uid, + 'methods': ['PUT'] + }]} + self.assertEqual(db_node['permissions'], expected_permissions) + + comment_id = payload['id'] + comment_url = flask.url_for('nodes_api.patch_node_comment', node_path=str(self.node_id), + comment_path=comment_id) # Edit the comment resp = self.patch( comment_url,