diff --git a/API.md b/API.md index caa2e61a..504a65fd 100644 --- a/API.md +++ b/API.md @@ -33,11 +33,18 @@ - ~~Updating post~~ - ~~Getting post~~ - ~~Deleting post~~ - - ~~Scoring posts~~ - - ~~Adding posts to favorites~~ - - ~~Removing posts from favorites~~ + - ~~Rating post~~ + - ~~Adding post to favorites~~ + - ~~Removing post from favorites~~ - [Getting featured post](#getting-featured-post) - [Featuring post](#featuring-post) + - Comments + - ~~Listing comments~~ + - [Creating comment](#creating-comment) + - ~~Updating comment~~ + - ~~Getting comment~~ + - ~~Deleting comment~~ + - ~~Rating comment~~ - Users - [Listing users](#listing-users) - [Creating user](#creating-user) @@ -58,6 +65,7 @@ - [Tag category](#tag-category) - [Tag](#tag) - [Post](#post) + - [Comment](#comment) - [Snapshot](#snapshot) 4. [Search](#search) @@ -665,6 +673,40 @@ data. Features a post on the main page in web client. +## Creating comment +- **Request** + + `POST /comments/` + +- **Input** + + ```json5 + { + "text": , + "postId": + } + ``` + +- **Output** + + ```json5 + { + "comment": + } + ``` + ...where `` is a [comment resource](#comment). + +- **Errors** + + - post does not exist + - comment text is empty + - privileges are too low + +- **Description** + + Creates a new comment under given post. + + ## Listing users - **Request** @@ -1174,6 +1216,32 @@ One file together with its metadata posted to the site. - ``: the last time the post was featured, formatted as per RFC 3339. +## Comment +**Description** + +A comment under a post. + +**Structure** + +```json5 +{ + "id": , + "post": , + "user": + "text": , + "creationTime": , + "lastEditTime": +} +``` + +**Field meaning** +- ``: the comment identifier. +- ``: a post resource the post is linked with. +- ``: a user resource the post is created by. +- ``: time the comment was created, formatted as per RFC 3339. +- ``: time the comment was edited, formatted as per RFC 3339. + + ## Snapshot **Description** diff --git a/config.yaml.dist b/config.yaml.dist index 07126e21..dd74cd79 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -118,5 +118,6 @@ privileges: 'comments:edit:any': mod 'comments:edit:own': regular_user 'comments:list': regular_user + 'comments:view': regular_user 'snapshots:list': power_user diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index 3548ecdf..f95f7765 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -10,6 +10,9 @@ from szurubooru.api.tag_api import ( from szurubooru.api.tag_category_api import ( TagCategoryListApi, TagCategoryDetailApi) +from szurubooru.api.comment_api import ( + CommentListApi, + CommentDetailApi) from szurubooru.api.post_api import PostFeatureApi from szurubooru.api.snapshot_api import SnapshotListApi from szurubooru.api.info_api import InfoApi diff --git a/server/szurubooru/api/comment_api.py b/server/szurubooru/api/comment_api.py new file mode 100644 index 00000000..2dba81f9 --- /dev/null +++ b/server/szurubooru/api/comment_api.py @@ -0,0 +1,29 @@ +from szurubooru.api.base_api import BaseApi +from szurubooru.func import auth, comments, posts + +class CommentListApi(BaseApi): + def get(self, ctx): + raise NotImplementedError() + + def post(self, ctx): + auth.verify_privilege(ctx.user, 'comments:create') + + text = ctx.get_param_as_string('text', required=True) + post_id = ctx.get_param_as_int('postId', required=True) + post = posts.get_post_by_id(post_id) + if not post: + raise posts.PostNotFoundError('Post %r not found.' % post_id) + comment = comments.create_comment(ctx.user, post, text) + ctx.session.add(comment) + ctx.session.commit() + return {'comment': comments.serialize_comment(comment, ctx.user)} + +class CommentDetailApi(BaseApi): + def get(self, ctx, comment_id): + raise NotImplementedError() + + def put(self, ctx, comment_id): + raise NotImplementedError() + + def delete(self, ctx, comment_id): + raise NotImplementedError() diff --git a/server/szurubooru/api/context.py b/server/szurubooru/api/context.py index 9c4fe68f..3b725fc1 100644 --- a/server/szurubooru/api/context.py +++ b/server/szurubooru/api/context.py @@ -29,7 +29,11 @@ class Context(object): if name in self.input: param = self.input[name] if isinstance(param, list): - param = ','.join(param) + try: + param = ','.join(param) + except: + raise errors.ValidationError( + 'Parameter %r is invalid - expected simple string.' % name) return param if not required: return default diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py index 729351ea..bae728e4 100644 --- a/server/szurubooru/app.py +++ b/server/szurubooru/app.py @@ -58,6 +58,8 @@ def create_app(): post_feature_api = api.PostFeatureApi() password_reset_api = api.PasswordResetApi() snapshot_list_api = api.SnapshotListApi() + comment_list_api = api.CommentListApi() + comment_detail_api = api.CommentDetailApi() info_api = api.InfoApi() app.add_error_handler(errors.AuthError, _on_auth_error) @@ -79,5 +81,7 @@ def create_app(): app.add_route('/snapshots/', snapshot_list_api) app.add_route('/info/', info_api) app.add_route('/featured-post/', post_feature_api) + app.add_route('/comments/', comment_list_api) + app.add_route('/comment/{comment_id}', comment_detail_api) return app diff --git a/server/szurubooru/func/comments.py b/server/szurubooru/func/comments.py new file mode 100644 index 00000000..2ee34397 --- /dev/null +++ b/server/szurubooru/func/comments.py @@ -0,0 +1,29 @@ +import datetime +from szurubooru import db, errors +from szurubooru.func import users, posts + +class CommentNotFoundError(errors.NotFoundError): pass +class EmptyCommentTextError(errors.ValidationError): pass + +def serialize_comment(comment, authenticated_user): + return { + 'id': comment.comment_id, + 'user': users.serialize_user(comment.user, authenticated_user), + 'post': posts.serialize_post(comment.post, authenticated_user), + 'text': comment.text, + 'creationTime': comment.creation_time, + 'lastEditTime': comment.last_edit_time, + } + +def create_comment(user, post, text): + comment = db.Comment() + comment.user = user + comment.post = post + update_comment_text(comment, text) + comment.creation_time = datetime.datetime.now() + return comment + +def update_comment_text(comment, text): + if not text: + raise EmptyCommentTextError('Comment text cannot be empty.') + comment.text = text diff --git a/server/szurubooru/tests/api/test_comment_creating.py b/server/szurubooru/tests/api/test_comment_creating.py new file mode 100644 index 00000000..efa9130f --- /dev/null +++ b/server/szurubooru/tests/api/test_comment_creating.py @@ -0,0 +1,89 @@ +import datetime +import pytest +from szurubooru import api, db, errors +from szurubooru.func import util, posts + +@pytest.fixture +def test_ctx(config_injector, context_factory, post_factory, user_factory): + config_injector({ + 'ranks': ['anonymous', 'regular_user'], + 'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'}, + 'privileges': {'comments:create': 'regular_user'}, + 'thumbnails': {'avatar_width': 200}, + }) + ret = util.dotdict() + ret.context_factory = context_factory + ret.post_factory = post_factory + ret.user_factory = user_factory + ret.api = api.CommentListApi() + return ret + +def test_creating_comment(test_ctx, fake_datetime): + post = test_ctx.post_factory() + user = test_ctx.user_factory(rank='regular_user') + db.session.add_all([post, user]) + db.session.flush() + with fake_datetime('1997-01-01'): + result = test_ctx.api.post( + test_ctx.context_factory( + input={'text': 'input', 'postId': post.post_id}, + user=user)) + assert result['comment']['text'] == 'input' + assert 'id' in result['comment'] + assert 'user' in result['comment'] + assert 'post' in result['comment'] + assert 'name' in result['comment']['user'] + assert 'id' in result['comment']['post'] + comment = db.session.query(db.Comment).one() + assert comment.text == 'input' + assert comment.creation_time == datetime.datetime(1997, 1, 1) + assert comment.last_edit_time is None + assert comment.user and comment.user.user_id == user.user_id + assert comment.post and comment.post.post_id == post.post_id + +@pytest.mark.parametrize('input', [ + {'text': None}, + {'text': ''}, + {'text': [None]}, + {'text': ['']}, +]) +def test_trying_to_pass_invalid_input(test_ctx, input): + post = test_ctx.post_factory() + user = test_ctx.user_factory(rank='regular_user') + db.session.add_all([post, user]) + db.session.flush() + real_input = {'text': 'input', 'postId': post.post_id} + for key, value in input.items(): + real_input[key] = value + with pytest.raises(errors.ValidationError): + test_ctx.api.post( + test_ctx.context_factory(input=real_input, user=user)) + +@pytest.mark.parametrize('field', ['text', 'postId']) +def test_trying_to_omit_mandatory_field(test_ctx, field): + input = { + 'text': 'input', + 'postId': 1, + } + del input[field] + with pytest.raises(errors.ValidationError): + test_ctx.api.post( + test_ctx.context_factory( + input={}, + user=test_ctx.user_factory(rank='regular_user'))) + +def test_trying_to_comment_non_existing(test_ctx): + user = test_ctx.user_factory(rank='regular_user') + db.session.add_all([user]) + db.session.flush() + with pytest.raises(posts.PostNotFoundError): + test_ctx.api.post( + test_ctx.context_factory( + input={'text': 'bad', 'postId': 5}, user=user)) + +def test_trying_to_create_without_privileges(test_ctx): + with pytest.raises(errors.AuthError): + test_ctx.api.post( + test_ctx.context_factory( + input={}, + user=test_ctx.user_factory(rank='anonymous'))) diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py index f99934dc..be4162d6 100644 --- a/server/szurubooru/tests/conftest.py +++ b/server/szurubooru/tests/conftest.py @@ -101,7 +101,7 @@ def user_factory(): return factory @pytest.fixture -def tag_category_factory(session): +def tag_category_factory(): def factory(name='dummy', color='dummy'): category = db.TagCategory() category.name = name @@ -110,11 +110,11 @@ def tag_category_factory(session): return factory @pytest.fixture -def tag_factory(session): +def tag_factory(): def factory(names=None, category=None, category_name='dummy'): if not category: category = db.TagCategory(category_name) - session.add(category) + db.session.add(category) tag = db.Tag() tag.names = [db.TagName(name) for name in (names or [get_unique_name()])] tag.category = category @@ -140,11 +140,17 @@ def post_factory(): return factory @pytest.fixture -def comment_factory(): +def comment_factory(user_factory, post_factory): def factory(user=None, post=None, text='dummy'): + if not user: + user = user_factory() + db.session.add(user) + if not post: + post = post_factory() + db.session.add(post) comment = db.Comment() - comment.user = user or user_factory() - comment.post = post or post_factory() + comment.user = user + comment.post = post comment.text = text comment.creation_time = datetime.datetime(1996, 1, 1) return comment