server/posts: add post featuring

This commit is contained in:
rr- 2016-04-22 20:58:04 +02:00
parent a30886cc70
commit cf00a3a2de
13 changed files with 429 additions and 19 deletions

129
API.md
View file

@ -27,6 +27,16 @@
- [Deleting tag](#deleting-tag) - [Deleting tag](#deleting-tag)
- [Merging tags](#merging-tags) - [Merging tags](#merging-tags)
- [Listing tag siblings](#listing-tag-siblings) - [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 - Users
- [Listing users](#listing-users) - [Listing users](#listing-users)
- [Creating user](#creating-user) - [Creating user](#creating-user)
@ -44,6 +54,7 @@
- [User](#user) - [User](#user)
- [Tag category](#tag-category) - [Tag category](#tag-category)
- [Tag](#tag) - [Tag](#tag)
- [Post](#post)
- [Snapshot](#snapshot) - [Snapshot](#snapshot)
4. [Search](#search) 4. [Search](#search)
@ -590,6 +601,36 @@ data.
list is truncated to the first 50 elements. Doesn't use paging. list is truncated to the first 50 elements. Doesn't use paging.
## Featuring post
- **Request**
`POST /featured-post`
- **Output**
```json5
{
"post": <post>,
"snapshots": [
<snapshot>,
<snapshot>,
<snapshot>
]
}
```
...where `<post>` 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 ## Listing users
- **Request** - **Request**
@ -1007,7 +1048,76 @@ A single tag. Tags are used to let users search for posts.
- `<suggestions>`: a list of suggested tag names. Suggested tags are shown to - `<suggestions>`: a list of suggested tag names. Suggested tags are shown to
the user by the web client on usage. the user by the web client on usage.
- `<creation-time>`: time the tag was created, formatted as per RFC 3339. - `<creation-time>`: time the tag was created, formatted as per RFC 3339.
- `<creation-time>`: time the tag was edited, formatted as per RFC 3339. - `<last-edit-time>`: 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": <id>,
"safety": <safety>,
"type": <type>,
"checksum": <checksum>,
"source": <source>,
"canvasWidth": <canvas-width>,
"canvasHeight": <canvas-height>,
"flags": <flags>,
"tags": <tags>,
"relations": <relations>,
"creationTime": <creation-time>,
"lastEditTime": <last-edit-time>,
"user": <user>,
"score": <score>,
"favoritedBy": <favorited-by>,
"featureCount": <feature-count>,
"lastFeatureTime": <last-feature-time>,
}
```
**Field meaning**
- `<id>`: the post identifier.
- `<safety>`: whether the post is safe for work.
Available values:
- `"safe"`
- `"sketchy"`
- `"unsafe"`
- `<type>`: the type of the post.
Available values:
- `"image"` - plain image.
- `"animation"` - animated image (GIF).
- `"video"` - WEBM video.
- `"flash"` - Flash animation / game.
- `"youtube"` - Youtube embed.
- `<checksum>`: the file checksum. Used in snapshots to signify changes of the
post content.
- `<source>`: where the post was grabbed form, supplied by the user.
- `<canvas-width>` and `<canvas-height>`: the original width and height of the
post content.
- `<flags>`: various flags such as whether the post is looped, represented as
array of plain strings.
- `<tags>`: list of tag names the post is tagged with.
- `<relations>`: a list of related post IDs. Links to related posts are shown
to the user by the web client.
- `<creation-time>`: time the tag was created, formatted as per RFC 3339.
- `<last-edit-time>`: time the tag was edited, formatted as per RFC 3339.
- `<user>`: who created the post, serialized as [user resource](#user).
- `<score>`: the score (+1/-1 rating) of the given post.
- `<favorited-by>`: list of users, serialized as [user resources](#user).
- `<feature-count>`: how many times has the post been featured.
- `<last-feature-time>`: the last time the post was featured, formatted as per
RFC 3339.
## Snapshot ## Snapshot
**Description** **Description**
@ -1078,6 +1188,23 @@ A snapshot is a version of a database resource.
} }
``` ```
- Post snapshot data (`<resource-type> = "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
}
```
- `<earlier-data>`: `<data>` field from the last snapshot of the same resource. - `<earlier-data>`: `<data>` field from the last snapshot of the same resource.
This allows the client to create visual diffs for any given snapshot without This allows the client to create visual diffs for any given snapshot without
the need to know any other snapshots for a given resource. the need to know any other snapshots for a given resource.

View file

@ -2,8 +2,15 @@
from szurubooru.api.password_reset_api import PasswordResetApi from szurubooru.api.password_reset_api import PasswordResetApi
from szurubooru.api.user_api import UserListApi, UserDetailApi from szurubooru.api.user_api import UserListApi, UserDetailApi
from szurubooru.api.tag_api import TagListApi, TagDetailApi, TagMergeApi, TagSiblingsApi from szurubooru.api.tag_api import (
from szurubooru.api.tag_category_api import TagCategoryListApi, TagCategoryDetailApi 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.snapshot_api import SnapshotListApi
from szurubooru.api.info_api import InfoApi from szurubooru.api.info_api import InfoApi
from szurubooru.api.context import Context, Request from szurubooru.api.context import Context, Request

View file

@ -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)

View file

@ -3,7 +3,10 @@ from szurubooru import config, search
from szurubooru.api.base_api import BaseApi from szurubooru.api.base_api import BaseApi
from szurubooru.func import auth, users from szurubooru.func import auth, users
def _serialize_user(authenticated_user, user): def serialize_user(user, authenticated_user):
if not user:
return {}
ret = { ret = {
'name': user.name, 'name': user.name,
'rank': user.rank, 'rank': user.rank,
@ -36,7 +39,7 @@ class UserListApi(BaseApi):
def get(self, ctx): def get(self, ctx):
auth.verify_privilege(ctx.user, 'users:list') auth.verify_privilege(ctx.user, 'users:list')
return self._search_executor.execute_and_serialize( 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): def post(self, ctx):
auth.verify_privilege(ctx.user, 'users:create') auth.verify_privilege(ctx.user, 'users:create')
@ -58,7 +61,7 @@ class UserListApi(BaseApi):
ctx.session.add(user) ctx.session.add(user)
ctx.session.commit() ctx.session.commit()
return {'user': _serialize_user(ctx.user, user)} return {'user': serialize_user(user, ctx.user)}
class UserDetailApi(BaseApi): class UserDetailApi(BaseApi):
def get(self, ctx, user_name): def get(self, ctx, user_name):
@ -66,7 +69,7 @@ class UserDetailApi(BaseApi):
user = users.get_user_by_name(user_name) user = users.get_user_by_name(user_name)
if not user: if not user:
raise users.UserNotFoundError('User %r not found.' % user_name) 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): def put(self, ctx, user_name):
user = users.get_user_by_name(user_name) user = users.get_user_by_name(user_name)
@ -102,7 +105,7 @@ class UserDetailApi(BaseApi):
ctx.get_file('avatar')) ctx.get_file('avatar'))
ctx.session.commit() ctx.session.commit()
return {'user': _serialize_user(ctx.user, user)} return {'user': serialize_user(user, ctx.user)}
def delete(self, ctx, user_name): def delete(self, ctx, user_name):
user = users.get_user_by_name(user_name) user = users.get_user_by_name(user_name)

View file

@ -55,6 +55,7 @@ def create_app():
tag_detail_api = api.TagDetailApi() tag_detail_api = api.TagDetailApi()
tag_merge_api = api.TagMergeApi() tag_merge_api = api.TagMergeApi()
tag_siblings_api = api.TagSiblingsApi() tag_siblings_api = api.TagSiblingsApi()
post_feature_api = api.PostFeatureApi()
password_reset_api = api.PasswordResetApi() password_reset_api = api.PasswordResetApi()
snapshot_list_api = api.SnapshotListApi() snapshot_list_api = api.SnapshotListApi()
info_api = api.InfoApi() info_api = api.InfoApi()
@ -77,5 +78,6 @@ def create_app():
app.add_route('/password-reset/{user_name}', password_reset_api) app.add_route('/password-reset/{user_name}', password_reset_api)
app.add_route('/snapshots/', snapshot_list_api) app.add_route('/snapshots/', snapshot_list_api)
app.add_route('/info/', info_api) app.add_route('/info/', info_api)
app.add_route('/featured-post/', post_feature_api)
return app return app

View file

@ -1,5 +1,5 @@
from sqlalchemy import Column, Integer, DateTime, String, Text, PickleType, ForeignKey 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 sqlalchemy.sql.expression import func, select
from szurubooru.db.base import Base from szurubooru.db.base import Base
@ -87,9 +87,9 @@ class Post(Base):
checksum = Column('checksum', String(64), nullable=False) checksum = Column('checksum', String(64), nullable=False)
source = Column('source', String(200)) source = Column('source', String(200))
file_size = Column('file_size', Integer) file_size = Column('file_size', Integer)
image_width = Column('image_width', Integer) canvas_width = Column('image_width', Integer)
image_height = Column('image_height', Integer) canvas_height = Column('image_height', Integer)
flags = Column('flags', Integer, nullable=False, default=0) flags = Column('flags', PickleType, default=None)
user = relationship('User') user = relationship('User')
tags = relationship('Tag', backref='posts', secondary='post_tag') tags = relationship('Tag', backref='posts', secondary='post_tag')
@ -102,7 +102,7 @@ class Post(Base):
'PostFeature', cascade='all, delete-orphan', lazy='joined') 'PostFeature', cascade='all, delete-orphan', lazy='joined')
scores = relationship( scores = relationship(
'PostScore', cascade='all, delete-orphan', lazy='joined') 'PostScore', cascade='all, delete-orphan', lazy='joined')
favorites = relationship( favorited_by = relationship(
'PostFavorite', cascade='all, delete-orphan', lazy='joined') 'PostFavorite', cascade='all, delete-orphan', lazy='joined')
notes = relationship( notes = relationship(
'PostNote', cascade='all, delete-orphan', lazy='joined') 'PostNote', cascade='all, delete-orphan', lazy='joined')
@ -112,8 +112,16 @@ class Post(Base):
.where(PostTag.post_id == post_id) \ .where(PostTag.post_id == post_id) \
.correlate_except(PostTag)) .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 # 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) score = Column('auto_score', Integer, nullable=False, default=0)
feature_count = Column('auto_feature_count', 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) comment_count = Column('auto_comment_count', Integer, nullable=False, default=0)

View file

@ -1,5 +1,28 @@
import datetime
import sqlalchemy 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(): def get_post_count():
return db.session.query(sqlalchemy.func.count(db.Post.post_id)).one()[0] 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)

View file

@ -3,13 +3,27 @@ from sqlalchemy.inspection import inspect
from szurubooru import db from szurubooru import db
def get_tag_snapshot(tag): def get_tag_snapshot(tag):
ret = { return {
'names': [tag_name.name for tag_name in tag.names], 'names': [tag_name.name for tag_name in tag.names],
'category': tag.category.name, 'category': tag.category.name,
'suggestions': sorted(rel.first_name for rel in tag.suggestions), 'suggestions': sorted(rel.first_name for rel in tag.suggestions),
'implications': sorted(rel.first_name for rel in tag.implications), '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): def get_tag_category_snapshot(category):
return { return {
@ -25,6 +39,9 @@ serializers = {
'tag_category': ( 'tag_category': (
get_tag_category_snapshot, get_tag_category_snapshot,
lambda category: category.name), lambda category: category.name),
'post': (
get_post_snapshot,
lambda post: post.post_id),
} }
def get_resource_info(entity): def get_resource_info(entity):

View file

@ -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))

View file

@ -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')))

View file

@ -125,14 +125,16 @@ def tag_factory(session):
@pytest.fixture @pytest.fixture
def post_factory(): def post_factory():
def factory( def factory(
id=None,
safety=db.Post.SAFETY_SAFE, safety=db.Post.SAFETY_SAFE,
type=db.Post.TYPE_IMAGE, type=db.Post.TYPE_IMAGE,
checksum='...'): checksum='...'):
post = db.Post() post = db.Post()
post.post_id = id
post.safety = safety post.safety = safety
post.type = type post.type = type
post.checksum = checksum post.checksum = checksum
post.flags = 0 post.flags = []
post.creation_time = datetime.datetime(1996, 1, 1) post.creation_time = datetime.datetime(1996, 1, 1)
return post return post
return factory return factory

View file

@ -68,7 +68,7 @@ def test_cascade_deletions(post_factory, user_factory, tag_factory):
post.relations.append(related_post1) post.relations.append(related_post1)
post.relations.append(related_post2) post.relations.append(related_post2)
post.scores.append(score) post.scores.append(score)
post.favorites.append(favorite) post.favorited_by.append(favorite)
post.features.append(feature) post.features.append(feature)
post.notes.append(note) post.notes.append(note)
db.session.flush() db.session.flush()

View file

@ -3,6 +3,65 @@ import pytest
from szurubooru import db from szurubooru import db
from szurubooru.func import snapshots 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): def test_serializing_tag(tag_factory):
tag = tag_factory(names=['main_name', 'alias'], category_name='dummy') tag = tag_factory(names=['main_name', 'alias'], category_name='dummy')
assert snapshots.get_tag_snapshot(tag) == { assert snapshots.get_tag_snapshot(tag) == {