diff --git a/API.md b/API.md index a6564b6d..3da187ec 100644 --- a/API.md +++ b/API.md @@ -34,8 +34,8 @@ - [Getting post](#getting-post) - [Deleting post](#deleting-post) - [Rating post](#rating-post) - - ~~Adding post to favorites~~ - - ~~Removing post from favorites~~ + - [Adding post to favorites](#adding-post-to-favorites) + - [Removing post from favorites](#removing-post-from-favorites) - [Getting featured post](#getting-featured-post) - [Featuring post](#featuring-post) - Comments @@ -590,6 +590,44 @@ data. and 1. +## Adding post to favorites +- **Request** + + `POST /post//favorite` + +- **Output** + + A [detailed post resource](#detailed-post). + +- **Errors** + + - post does not exist + - privileges are too low + +- **Description** + + Marks the post as favorite for authenticated user. + + +## Removing post from favorites +- **Request** + + `DELETE /post//favorite` + +- **Output** + + A [detailed post resource](#detailed-post). + +- **Errors** + + - post does not exist + - privileges are too low + +- **Description** + + Unmarks the post as favorite for authenticated user. + + ## Getting featured post - **Request** diff --git a/config.yaml.dist b/config.yaml.dist index 016fcd17..b35205b1 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -94,6 +94,7 @@ privileges: 'posts:feature': mod 'posts:delete': mod 'posts:score': regular_user + 'posts:favorite': regular_user 'tags:create': regular_user 'tags:edit:names': power_user diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index 94795200..fb7324bc 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -17,7 +17,8 @@ from szurubooru.api.comment_api import ( from szurubooru.api.post_api import ( PostDetailApi, PostFeatureApi, - PostScoreApi) + PostScoreApi, + PostFavoriteApi) from szurubooru.api.snapshot_api import SnapshotListApi from szurubooru.api.info_api import InfoApi from szurubooru.api.context import Context, Request diff --git a/server/szurubooru/api/post_api.py b/server/szurubooru/api/post_api.py index 0c08e82e..df1c32e2 100644 --- a/server/szurubooru/api/post_api.py +++ b/server/szurubooru/api/post_api.py @@ -1,5 +1,5 @@ from szurubooru.api.base_api import BaseApi -from szurubooru.func import auth, tags, posts, snapshots, scores +from szurubooru.func import auth, tags, posts, snapshots, favorites, scores class PostDetailApi(BaseApi): def get(self, ctx, post_id): @@ -51,3 +51,18 @@ class PostScoreApi(BaseApi): scores.delete_score(post, ctx.user) ctx.session.commit() return posts.serialize_post_with_details(post, ctx.user) + +class PostFavoriteApi(BaseApi): + def post(self, ctx, post_id): + auth.verify_privilege(ctx.user, 'posts:favorite') + post = posts.get_post_by_id(post_id) + favorites.set_favorite(post, ctx.user) + ctx.session.commit() + return posts.serialize_post_with_details(post, ctx.user) + + def delete(self, ctx, post_id): + auth.verify_privilege(ctx.user, 'posts:favorite') + post = posts.get_post_by_id(post_id) + favorites.unset_favorite(post, ctx.user) + ctx.session.commit() + return posts.serialize_post_with_details(post, ctx.user) diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py index 6a459f66..85ccda8d 100644 --- a/server/szurubooru/app.py +++ b/server/szurubooru/app.py @@ -67,6 +67,7 @@ def create_app(): app.add_route('/post/{post_id}', api.PostDetailApi()) app.add_route('/post/{post_id}/score', api.PostScoreApi()) + app.add_route('/post/{post_id}/favorite', api.PostFavoriteApi()) app.add_route('/comments/', api.CommentListApi()) app.add_route('/comment/{comment_id}', api.CommentDetailApi()) diff --git a/server/szurubooru/db/post.py b/server/szurubooru/db/post.py index 4d8deb0b..8d6b496b 100644 --- a/server/szurubooru/db/post.py +++ b/server/szurubooru/db/post.py @@ -128,6 +128,16 @@ class Post(Base): .filter(PostScore.post_id == self.post_id) \ .one()[0] or 0 + favorite_count = column_property( + select([func.count(PostFavorite.post_id)]) \ + .where(PostFavorite.post_id == post_id) \ + .correlate_except(PostFavorite)) + + last_favorite_time = column_property( + select([func.max(PostFavorite.time)]) \ + .where(PostFavorite.post_id == post_id) \ + .correlate_except(PostFavorite)) + feature_count = column_property( select([func.count(PostFeature.post_id)]) \ .where(PostFeature.post_id == post_id) \ @@ -139,11 +149,8 @@ class Post(Base): .correlate_except(PostFeature)) # TODO: wire these - #favorite_count = Column('auto_fav_count', Integer, nullable=False, default=0) #comment_count = Column('auto_comment_count', Integer, nullable=False, default=0) #note_count = Column('auto_note_count', Integer, nullable=False, default=0) - #last_fav_time = Column( - # 'auto_fav_time', Integer, nullable=False, default=0) #last_comment_edit_time = Column( # 'auto_comment_creation_time', Integer, nullable=False, default=0) #last_comment_creation_time = Column( diff --git a/server/szurubooru/func/favorites.py b/server/szurubooru/func/favorites.py new file mode 100644 index 00000000..9c3dda7c --- /dev/null +++ b/server/szurubooru/func/favorites.py @@ -0,0 +1,36 @@ +import datetime +from szurubooru import db +from szurubooru.func import util + +def _get_table_info(entity): + resource_type, _, _ = util.get_resource_info(entity) + if resource_type == 'post': + return db.PostFavorite, lambda table: table.post_id + else: + assert False + +def _get_fav_entity(entity, user): + table, get_column = _get_table_info(entity) + return db.session \ + .query(table) \ + .filter(get_column(table) == get_column(entity)) \ + .filter(table.user_id == user.user_id) \ + .one_or_none() + +def has_favorited(entity, user): + return _get_fav_entity(entity, user) is not None + +def unset_favorite(entity, user): + fav_entity = _get_fav_entity(entity, user) + if fav_entity: + db.session.delete(fav_entity) + +def set_favorite(entity, user): + fav_entity = _get_fav_entity(entity, user) + if not fav_entity: + table, get_column = _get_table_info(entity) + fav_entity = table() + setattr(fav_entity, get_column(table).name, get_column(entity)) + fav_entity.user = user + fav_entity.time = datetime.datetime.now() + db.session.add(fav_entity) diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index 3a3f7a34..7e1524a8 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -32,7 +32,7 @@ def serialize_post(post, authenticated_user): 'score': post.score, 'featureCount': post.feature_count, 'lastFeatureTime': post.last_feature_time, - 'favoritedBy': [users.serialize_user(rel, authenticated_user) \ + 'favoritedBy': [users.serialize_user(rel.user, authenticated_user) \ for rel in post.favorited_by], } diff --git a/server/szurubooru/tests/api/test_post_favoriting.py b/server/szurubooru/tests/api/test_post_favoriting.py new file mode 100644 index 00000000..53ed6607 --- /dev/null +++ b/server/szurubooru/tests/api/test_post_favoriting.py @@ -0,0 +1,124 @@ +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, user_factory, post_factory): + config_injector({ + 'ranks': ['anonymous', 'regular_user'], + 'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'}, + 'privileges': {'posts:favorite': 'regular_user'}, + 'thumbnails': {'avatar_width': 200}, + }) + db.session.flush() + ret = util.dotdict() + ret.context_factory = context_factory + ret.user_factory = user_factory + ret.post_factory = post_factory + ret.api = api.PostFavoriteApi() + return ret + +def test_simple_rating(test_ctx, fake_datetime): + post = test_ctx.post_factory() + db.session.add(post) + db.session.commit() + with fake_datetime('1997-12-01'): + result = test_ctx.api.post( + test_ctx.context_factory(user=test_ctx.user_factory()), + post.post_id) + assert 'post' in result + assert 'id' in result['post'] + post = db.session.query(db.Post).one() + assert db.session.query(db.PostFavorite).count() == 1 + assert post is not None + assert post.favorite_count == 1 + +def test_removing_from_favorites(test_ctx, fake_datetime): + user = test_ctx.user_factory() + post = test_ctx.post_factory() + db.session.add(post) + db.session.commit() + with fake_datetime('1997-12-01'): + result = test_ctx.api.post( + test_ctx.context_factory(user=user), + post.post_id) + with fake_datetime('1997-12-02'): + result = test_ctx.api.delete( + test_ctx.context_factory(user=user), + post.post_id) + post = db.session.query(db.Post).one() + assert db.session.query(db.PostFavorite).count() == 0 + assert post.favorite_count == 0 + +def test_favoriting_twice(test_ctx, fake_datetime): + user = test_ctx.user_factory() + post = test_ctx.post_factory() + db.session.add(post) + db.session.commit() + with fake_datetime('1997-12-01'): + result = test_ctx.api.post( + test_ctx.context_factory(user=user), + post.post_id) + with fake_datetime('1997-12-02'): + result = test_ctx.api.post( + test_ctx.context_factory(user=user), + post.post_id) + post = db.session.query(db.Post).one() + assert db.session.query(db.PostFavorite).count() == 1 + assert post.favorite_count == 1 + +def test_removing_twice(test_ctx, fake_datetime): + user = test_ctx.user_factory() + post = test_ctx.post_factory() + db.session.add(post) + db.session.commit() + with fake_datetime('1997-12-01'): + result = test_ctx.api.post( + test_ctx.context_factory(user=user), + post.post_id) + with fake_datetime('1997-12-02'): + result = test_ctx.api.delete( + test_ctx.context_factory(user=user), + post.post_id) + with fake_datetime('1997-12-02'): + result = test_ctx.api.delete( + test_ctx.context_factory(user=user), + post.post_id) + post = db.session.query(db.Post).one() + assert db.session.query(db.PostFavorite).count() == 0 + assert post.favorite_count == 0 + +def test_favorites_from_multiple_users(test_ctx, fake_datetime): + user1 = test_ctx.user_factory() + user2 = test_ctx.user_factory() + post = test_ctx.post_factory() + db.session.add_all([user1, user2, post]) + db.session.commit() + with fake_datetime('1997-12-01'): + result = test_ctx.api.post( + test_ctx.context_factory(user=user1), + post.post_id) + with fake_datetime('1997-12-02'): + result = test_ctx.api.post( + test_ctx.context_factory(user=user2), + post.post_id) + post = db.session.query(db.Post).one() + assert db.session.query(db.PostFavorite).count() == 2 + assert post.favorite_count == 2 + assert post.last_favorite_time == datetime.datetime(1997, 12, 2) + +def test_trying_to_update_non_existing(test_ctx): + with pytest.raises(posts.PostNotFoundError): + test_ctx.api.post( + test_ctx.context_factory(user=test_ctx.user_factory()), 5) + +def test_trying_to_rate_without_privileges(test_ctx): + post = test_ctx.post_factory() + db.session.add(post) + db.session.commit() + with pytest.raises(errors.AuthError): + test_ctx.api.post( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='anonymous')), + post.post_id) diff --git a/server/szurubooru/tests/api/test_post_rating.py b/server/szurubooru/tests/api/test_post_rating.py index 26e01db3..65486e52 100644 --- a/server/szurubooru/tests/api/test_post_rating.py +++ b/server/szurubooru/tests/api/test_post_rating.py @@ -1,4 +1,3 @@ -import datetime import pytest from szurubooru import api, db, errors from szurubooru.func import util, posts, scores