diff --git a/API.md b/API.md index 94b3f26..b301016 100644 --- a/API.md +++ b/API.md @@ -27,6 +27,16 @@ - [Deleting tag](#deleting-tag) - [Merging tags](#merging-tags) - [Listing tag siblings](#listing-tag-siblings) + - Posts + - ~~Listing posts~~ + - ~~Creating post~~ + - ~~Updating post~~ + - ~~Getting post~~ + - ~~Deleting post~~ + - ~~Scoring posts~~ + - ~~Adding posts to favorites~~ + - ~~Removing posts from favorites~~ + - [Featuring post](#featuring-post) - Users - [Listing users](#listing-users) - [Creating user](#creating-user) @@ -44,6 +54,7 @@ - [User](#user) - [Tag category](#tag-category) - [Tag](#tag) + - [Post](#post) - [Snapshot](#snapshot) 4. [Search](#search) @@ -590,6 +601,36 @@ data. list is truncated to the first 50 elements. Doesn't use paging. +## Featuring post +- **Request** + + `POST /featured-post` + +- **Output** + + ```json5 + { + "post": , + "snapshots": [ + , + , + + ] + } + ``` + ...where `` is a [post resource](#post), and `snapshots` contain its + earlier versions. + +- **Errors** + + - privileges are too low + - trying to feature a post that is currently featured + +- **Description** + + Features a post on the main page. + + ## Listing users - **Request** @@ -1007,7 +1048,76 @@ A single tag. Tags are used to let users search for posts. - ``: a list of suggested tag names. Suggested tags are shown to the user by the web client on usage. - ``: time the tag was created, formatted as per RFC 3339. -- ``: time the tag was edited, formatted as per RFC 3339. +- ``: time the tag was edited, formatted as per RFC 3339. + +## Post +**Description** + +One file together with its metadata posted to the site. + +**Structure** + +```json5 +{ + "id": , + "safety": , + "type": , + "checksum": , + "source": , + "canvasWidth": , + "canvasHeight": , + "flags": , + "tags": , + "relations": , + "creationTime": , + "lastEditTime": , + "user": , + "score": , + "favoritedBy": , + "featureCount": , + "lastFeatureTime": , +} +``` + +**Field meaning** + +- ``: the post identifier. +- ``: whether the post is safe for work. + + Available values: + + - `"safe"` + - `"sketchy"` + - `"unsafe"` + +- ``: the type of the post. + + Available values: + + - `"image"` - plain image. + - `"animation"` - animated image (GIF). + - `"video"` - WEBM video. + - `"flash"` - Flash animation / game. + - `"youtube"` - Youtube embed. + +- ``: the file checksum. Used in snapshots to signify changes of the + post content. +- ``: where the post was grabbed form, supplied by the user. +- `` and ``: the original width and height of the + post content. +- ``: various flags such as whether the post is looped, represented as + array of plain strings. +- ``: list of tag names the post is tagged with. +- ``: a list of related post IDs. Links to related posts are shown + to the user by the web client. +- ``: time the tag was created, formatted as per RFC 3339. +- ``: time the tag was edited, formatted as per RFC 3339. +- ``: who created the post, serialized as [user resource](#user). +- ``: the score (+1/-1 rating) of the given post. +- ``: list of users, serialized as [user resources](#user). +- ``: how many times has the post been featured. +- ``: the last time the post was featured, formatted as per + RFC 3339. ## Snapshot **Description** @@ -1078,6 +1188,23 @@ A snapshot is a version of a database resource. } ``` + - Post snapshot data (` = "post"`) + + *Example* + + ```json5 + { + "source": "http://example.com/", + "safety": "safe", + "checksum": "deadbeef", + "tags": ["tag1", "tag2"], + "relations": [1, 2], + "notes": [{"polygon": [[1,1],[200,1],[200,200],[1,200]], "text": "..."}], + "flags": ["loop"], + "featured": false + } + ``` + - ``: `` field from the last snapshot of the same resource. This allows the client to create visual diffs for any given snapshot without the need to know any other snapshots for a given resource. diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index fd68915..3548ecd 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -2,8 +2,15 @@ from szurubooru.api.password_reset_api import PasswordResetApi from szurubooru.api.user_api import UserListApi, UserDetailApi -from szurubooru.api.tag_api import TagListApi, TagDetailApi, TagMergeApi, TagSiblingsApi -from szurubooru.api.tag_category_api import TagCategoryListApi, TagCategoryDetailApi +from szurubooru.api.tag_api import ( + TagListApi, + TagDetailApi, + TagMergeApi, + TagSiblingsApi) +from szurubooru.api.tag_category_api import ( + TagCategoryListApi, + TagCategoryDetailApi) +from szurubooru.api.post_api import PostFeatureApi 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 new file mode 100644 index 0000000..e38c84f --- /dev/null +++ b/server/szurubooru/api/post_api.py @@ -0,0 +1,58 @@ +from szurubooru.api.base_api import BaseApi +from szurubooru.api.user_api import serialize_user +from szurubooru.func import auth, posts, snapshots + +def serialize_post(post, authenticated_user): + ret = { + 'id': post.post_id, + 'creationTime': post.creation_time, + 'lastEditTime': post.last_edit_time, + 'safety': post.safety, + 'type': post.type, + 'checksum': post.checksum, + 'source': post.source, + 'fileSize': post.file_size, + 'canvasWidth': post.canvas_width, + 'canvasHeight': post.canvas_height, + 'flags': post.flags, + 'tags': [tag.first_name for tag in post.tags], + 'relations': [rel.post_id for rel in post.relations], + 'notes': sorted([{ + 'path': note.path, + 'text': note.text, + } for note in post.notes]), + 'user': serialize_user(post.user, authenticated_user), + 'score': post.score, + 'featureCount': post.feature_count, + 'lastFeatureTime': post.last_feature_time, + 'favoritedBy': [serialize_user(rel, authenticated_user) \ + for rel in post.favorited_by], + } + + # TODO: fetch own score if needed + + return ret + +def serialize_post_with_details(post, authenticated_user): + return { + 'post': serialize_post(post, authenticated_user), + 'snapshots': snapshots.get_serialized_history(post), + } + +class PostFeatureApi(BaseApi): + def post(self, ctx): + auth.verify_privilege(ctx.user, 'posts:feature') + post_id = ctx.get_param_as_int('id', required=True) + post = posts.get_post_by_id(post_id) + if not post: + raise posts.PostNotFoundError('Post %r not found.' % post_id) + featured_post = posts.get_featured_post() + if featured_post and featured_post.post_id == post.post_id: + raise posts.PostAlreadyFeaturedError( + 'Post %r is already featured.' % post_id) + posts.feature_post(post, ctx.user) + if featured_post: + snapshots.modify(featured_post, ctx.user) + snapshots.modify(post, ctx.user) + ctx.session.commit() + return serialize_post_with_details(post, ctx.user) diff --git a/server/szurubooru/api/user_api.py b/server/szurubooru/api/user_api.py index d1ee389..2164eaa 100644 --- a/server/szurubooru/api/user_api.py +++ b/server/szurubooru/api/user_api.py @@ -3,7 +3,10 @@ from szurubooru import config, search from szurubooru.api.base_api import BaseApi from szurubooru.func import auth, users -def _serialize_user(authenticated_user, user): +def serialize_user(user, authenticated_user): + if not user: + return {} + ret = { 'name': user.name, 'rank': user.rank, @@ -36,7 +39,7 @@ class UserListApi(BaseApi): def get(self, ctx): auth.verify_privilege(ctx.user, 'users:list') return self._search_executor.execute_and_serialize( - ctx, lambda user: _serialize_user(ctx.user, user), 'users') + ctx, lambda user: serialize_user(user, ctx.user), 'users') def post(self, ctx): auth.verify_privilege(ctx.user, 'users:create') @@ -58,7 +61,7 @@ class UserListApi(BaseApi): ctx.session.add(user) ctx.session.commit() - return {'user': _serialize_user(ctx.user, user)} + return {'user': serialize_user(user, ctx.user)} class UserDetailApi(BaseApi): def get(self, ctx, user_name): @@ -66,7 +69,7 @@ class UserDetailApi(BaseApi): user = users.get_user_by_name(user_name) if not user: raise users.UserNotFoundError('User %r not found.' % user_name) - return {'user': _serialize_user(ctx.user, user)} + return {'user': serialize_user(user, ctx.user)} def put(self, ctx, user_name): user = users.get_user_by_name(user_name) @@ -102,7 +105,7 @@ class UserDetailApi(BaseApi): ctx.get_file('avatar')) ctx.session.commit() - return {'user': _serialize_user(ctx.user, user)} + return {'user': serialize_user(user, ctx.user)} def delete(self, ctx, user_name): user = users.get_user_by_name(user_name) diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py index b6c285c..729351e 100644 --- a/server/szurubooru/app.py +++ b/server/szurubooru/app.py @@ -55,6 +55,7 @@ def create_app(): tag_detail_api = api.TagDetailApi() tag_merge_api = api.TagMergeApi() tag_siblings_api = api.TagSiblingsApi() + post_feature_api = api.PostFeatureApi() password_reset_api = api.PasswordResetApi() snapshot_list_api = api.SnapshotListApi() info_api = api.InfoApi() @@ -77,5 +78,6 @@ def create_app(): app.add_route('/password-reset/{user_name}', password_reset_api) app.add_route('/snapshots/', snapshot_list_api) app.add_route('/info/', info_api) + app.add_route('/featured-post/', post_feature_api) return app diff --git a/server/szurubooru/db/post.py b/server/szurubooru/db/post.py index 85e4476..3c1972a 100644 --- a/server/szurubooru/db/post.py +++ b/server/szurubooru/db/post.py @@ -1,5 +1,5 @@ from sqlalchemy import Column, Integer, DateTime, String, Text, PickleType, ForeignKey -from sqlalchemy.orm import relationship, column_property +from sqlalchemy.orm import relationship, column_property, object_session from sqlalchemy.sql.expression import func, select from szurubooru.db.base import Base @@ -87,9 +87,9 @@ class Post(Base): checksum = Column('checksum', String(64), nullable=False) source = Column('source', String(200)) file_size = Column('file_size', Integer) - image_width = Column('image_width', Integer) - image_height = Column('image_height', Integer) - flags = Column('flags', Integer, nullable=False, default=0) + canvas_width = Column('image_width', Integer) + canvas_height = Column('image_height', Integer) + flags = Column('flags', PickleType, default=None) user = relationship('User') tags = relationship('Tag', backref='posts', secondary='post_tag') @@ -102,7 +102,7 @@ class Post(Base): 'PostFeature', cascade='all, delete-orphan', lazy='joined') scores = relationship( 'PostScore', cascade='all, delete-orphan', lazy='joined') - favorites = relationship( + favorited_by = relationship( 'PostFavorite', cascade='all, delete-orphan', lazy='joined') notes = relationship( 'PostNote', cascade='all, delete-orphan', lazy='joined') @@ -112,8 +112,16 @@ class Post(Base): .where(PostTag.post_id == post_id) \ .correlate_except(PostTag)) + @property + def is_featured(self): + featured_post = object_session(self) \ + .query(PostFeature) \ + .order_by(PostFeature.time.desc()) \ + .first() + return featured_post and featured_post.post_id == self.post_id + # TODO: wire these - fav_count = Column('auto_fav_count', Integer, nullable=False, default=0) + favorite_count = Column('auto_fav_count', Integer, nullable=False, default=0) score = Column('auto_score', Integer, nullable=False, default=0) feature_count = Column('auto_feature_count', Integer, nullable=False, default=0) comment_count = Column('auto_comment_count', Integer, nullable=False, default=0) diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index 027cf5e..f84cd26 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -1,5 +1,28 @@ +import datetime import sqlalchemy -from szurubooru import db +from szurubooru import db, errors + +class PostNotFoundError(errors.NotFoundError): pass +class PostAlreadyFeaturedError(errors.ValidationError): pass def get_post_count(): return db.session.query(sqlalchemy.func.count(db.Post.post_id)).one()[0] + +def get_post_by_id(post_id): + return db.session.query(db.Post) \ + .filter(db.Post.post_id == post_id) \ + .one_or_none() + +def get_featured_post(): + post_feature = db.session \ + .query(db.PostFeature) \ + .order_by(db.PostFeature.time.desc()) \ + .first() + return post_feature.post if post_feature else None + +def feature_post(post, user): + post_feature = db.PostFeature() + post_feature.time = datetime.datetime.now() + post_feature.post = post + post_feature.user = user + db.session.add(post_feature) diff --git a/server/szurubooru/func/snapshots.py b/server/szurubooru/func/snapshots.py index 3caf8cd..bbb13d8 100644 --- a/server/szurubooru/func/snapshots.py +++ b/server/szurubooru/func/snapshots.py @@ -3,13 +3,27 @@ from sqlalchemy.inspection import inspect from szurubooru import db def get_tag_snapshot(tag): - ret = { + return { 'names': [tag_name.name for tag_name in tag.names], 'category': tag.category.name, 'suggestions': sorted(rel.first_name for rel in tag.suggestions), 'implications': sorted(rel.first_name for rel in tag.implications), } - return ret + +def get_post_snapshot(post): + return { + 'source': post.source, + 'safety': post.safety, + 'checksum': post.checksum, + 'tags': sorted([tag.first_name for tag in post.tags]), + 'relations': sorted([rel.post_id for rel in post.relations]), + 'notes': sorted([{ + 'polygon': note.polygon, + 'text': note.text, + } for note in post.notes]), + 'flags': post.flags, + 'featured': post.is_featured, + } def get_tag_category_snapshot(category): return { @@ -25,6 +39,9 @@ serializers = { 'tag_category': ( get_tag_category_snapshot, lambda category: category.name), + 'post': ( + get_post_snapshot, + lambda post: post.post_id), } def get_resource_info(entity): diff --git a/server/szurubooru/migrations/versions/84bd402f15f0_change_flags_column_type.py b/server/szurubooru/migrations/versions/84bd402f15f0_change_flags_column_type.py new file mode 100644 index 0000000..adc040e --- /dev/null +++ b/server/szurubooru/migrations/versions/84bd402f15f0_change_flags_column_type.py @@ -0,0 +1,22 @@ +''' +Change flags column type + +Revision ID: 84bd402f15f0 +Created at: 2016-04-22 20:48:32.386159 +''' + +import sqlalchemy as sa +from alembic import op + +revision = '84bd402f15f0' +down_revision = '9587de88a84b' +branch_labels = None +depends_on = None + +def upgrade(): + op.drop_column('post', 'flags') + op.add_column('post', sa.Column('flags', sa.PickleType(), nullable=True)) + +def downgrade(): + op.drop_column('post', 'flags') + op.add_column('post', sa.Column('flags', sa.Integer(), autoincrement=False, nullable=False)) diff --git a/server/szurubooru/tests/api/test_post_featuring.py b/server/szurubooru/tests/api/test_post_featuring.py new file mode 100644 index 0000000..1172fc2 --- /dev/null +++ b/server/szurubooru/tests/api/test_post_featuring.py @@ -0,0 +1,82 @@ +import datetime +import pytest +from szurubooru import api, db, errors +from szurubooru.func import util, posts + +@pytest.fixture +def test_ctx(context_factory, config_injector, user_factory, post_factory): + config_injector({ + 'privileges': {'posts:feature': 'regular_user'}, + 'ranks': ['anonymous', 'regular_user'], + }) + ret = util.dotdict() + ret.context_factory = context_factory + ret.user_factory = user_factory + ret.post_factory = post_factory + ret.api = api.PostFeatureApi() + return ret + +def test_featuring(test_ctx): + db.session.add(test_ctx.post_factory(id=1)) + db.session.commit() + assert posts.get_featured_post() is None + assert not posts.get_post_by_id(1).is_featured + result = test_ctx.api.post( + test_ctx.context_factory( + input={'id': 1}, + user=test_ctx.user_factory(rank='regular_user'))) + assert posts.get_featured_post() is not None + assert posts.get_featured_post().post_id == 1 + assert posts.get_post_by_id(1).is_featured + assert 'post' in result + assert 'snapshots' in result + assert 'id' in result['post'] + +def test_trying_to_feature_the_same_post_twice(test_ctx): + db.session.add(test_ctx.post_factory(id=1)) + db.session.commit() + test_ctx.api.post( + test_ctx.context_factory( + input={'id': 1}, + user=test_ctx.user_factory(rank='regular_user'))) + with pytest.raises(posts.PostAlreadyFeaturedError): + test_ctx.api.post( + test_ctx.context_factory( + input={'id': 1}, + user=test_ctx.user_factory(rank='regular_user'))) + +def test_featuring_one_post_after_another(test_ctx, fake_datetime): + db.session.add(test_ctx.post_factory(id=1)) + db.session.add(test_ctx.post_factory(id=2)) + db.session.commit() + assert posts.get_featured_post() is None + assert not posts.get_post_by_id(1).is_featured + assert not posts.get_post_by_id(2).is_featured + with fake_datetime('1997'): + result = test_ctx.api.post( + test_ctx.context_factory( + input={'id': 1}, + user=test_ctx.user_factory(rank='regular_user'))) + with fake_datetime('1998'): + result = test_ctx.api.post( + test_ctx.context_factory( + input={'id': 2}, + user=test_ctx.user_factory(rank='regular_user'))) + assert posts.get_featured_post() is not None + assert posts.get_featured_post().post_id == 2 + assert not posts.get_post_by_id(1).is_featured + assert posts.get_post_by_id(2).is_featured + +def test_trying_to_feature_non_existing(test_ctx): + with pytest.raises(posts.PostNotFoundError): + test_ctx.api.post( + test_ctx.context_factory( + input={'id': 1}, + user=test_ctx.user_factory(rank='regular_user'))) + +def test_trying_to_feature_without_privileges(test_ctx): + with pytest.raises(errors.AuthError): + test_ctx.api.post( + test_ctx.context_factory( + input={'id': 1}, + user=test_ctx.user_factory(rank='anonymous'))) diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py index 57841c1..373e5d9 100644 --- a/server/szurubooru/tests/conftest.py +++ b/server/szurubooru/tests/conftest.py @@ -125,14 +125,16 @@ def tag_factory(session): @pytest.fixture def post_factory(): def factory( + id=None, safety=db.Post.SAFETY_SAFE, type=db.Post.TYPE_IMAGE, checksum='...'): post = db.Post() + post.post_id = id post.safety = safety post.type = type post.checksum = checksum - post.flags = 0 + post.flags = [] post.creation_time = datetime.datetime(1996, 1, 1) return post return factory diff --git a/server/szurubooru/tests/db/test_post.py b/server/szurubooru/tests/db/test_post.py index acc444f..90125d6 100644 --- a/server/szurubooru/tests/db/test_post.py +++ b/server/szurubooru/tests/db/test_post.py @@ -68,7 +68,7 @@ def test_cascade_deletions(post_factory, user_factory, tag_factory): post.relations.append(related_post1) post.relations.append(related_post2) post.scores.append(score) - post.favorites.append(favorite) + post.favorited_by.append(favorite) post.features.append(feature) post.notes.append(note) db.session.flush() diff --git a/server/szurubooru/tests/func/test_snapshots.py b/server/szurubooru/tests/func/test_snapshots.py index 4e421b5..cad9115 100644 --- a/server/szurubooru/tests/func/test_snapshots.py +++ b/server/szurubooru/tests/func/test_snapshots.py @@ -3,6 +3,65 @@ import pytest from szurubooru import db from szurubooru.func import snapshots +def test_serializing_post(post_factory, user_factory, tag_factory): + user = user_factory(name='dummy-user') + tag1 = tag_factory(names=['dummy-tag1']) + tag2 = tag_factory(names=['dummy-tag2']) + post = post_factory(id=1) + related_post1 = post_factory(id=2) + related_post2 = post_factory(id=3) + db.session.add_all([user, tag1, tag2, post, related_post1, related_post2]) + db.session.flush() + + score = db.PostScore() + score.post = post + score.user = user + score.time = datetime.datetime(1997, 1, 1) + score.score = 1 + favorite = db.PostFavorite() + favorite.post = post + favorite.user = user + favorite.time = datetime.datetime(1997, 1, 1) + feature = db.PostFeature() + feature.post = post + feature.user = user + feature.time = datetime.datetime(1997, 1, 1) + note = db.PostNote() + note.post = post + note.polygon = [(1, 1), (200, 1), (200, 200), (1, 200)] + note.text = 'some text' + db.session.add_all([score]) + db.session.flush() + + post.user = user + post.checksum = 'deadbeef' + post.source = 'example.com' + post.tags.append(tag1) + post.tags.append(tag2) + post.relations.append(related_post1) + post.relations.append(related_post2) + post.scores.append(score) + post.favorited_by.append(favorite) + post.features.append(feature) + post.notes.append(note) + + assert snapshots.get_post_snapshot(post) == { + 'checksum': 'deadbeef', + 'featured': True, + 'flags': [], + 'notes': [ + { + 'polygon': [(1, 1), (200, 1), (200, 200), (1, 200)], + 'text': 'some text', + } + ], + 'relations': [2, 3], + 'safety': 'safe', + 'source': 'example.com', + 'tags': ['dummy-tag1', 'dummy-tag2'], + } + + def test_serializing_tag(tag_factory): tag = tag_factory(names=['main_name', 'alias'], category_name='dummy') assert snapshots.get_tag_snapshot(tag) == {