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)
- [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": <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
- **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
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 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
**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.
This allows the client to create visual diffs for any given snapshot without
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.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

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

View file

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

View file

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

View file

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

View file

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

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

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_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()

View file

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