server/tags: add listing tag siblings
This commit is contained in:
parent
747c730688
commit
97bd935bb6
38
API.md
38
API.md
|
@ -26,6 +26,7 @@
|
||||||
- [Getting tag](#getting-tag)
|
- [Getting tag](#getting-tag)
|
||||||
- [Deleting tag](#deleting-tag)
|
- [Deleting tag](#deleting-tag)
|
||||||
- [Merging tags](#merging-tags)
|
- [Merging tags](#merging-tags)
|
||||||
|
- [Listing tag siblings](#listing-tag-siblings)
|
||||||
- Users
|
- Users
|
||||||
- [Listing users](#listing-users)
|
- [Listing users](#listing-users)
|
||||||
- [Creating user](#creating-user)
|
- [Creating user](#creating-user)
|
||||||
|
@ -116,7 +117,7 @@ data.
|
||||||
|
|
||||||
- **Description**
|
- **Description**
|
||||||
|
|
||||||
Lists all tag categories. Doesn't support paging.
|
Lists all tag categories. Doesn't use paging.
|
||||||
|
|
||||||
**Note**: independently, the server exports current tag category list
|
**Note**: independently, the server exports current tag category list
|
||||||
snapshots to the data directory under `tags.json` name. Its purpose is to
|
snapshots to the data directory under `tags.json` name. Its purpose is to
|
||||||
|
@ -552,6 +553,41 @@ data.
|
||||||
exception of the set of posts it's used in.
|
exception of the set of posts it's used in.
|
||||||
|
|
||||||
|
|
||||||
|
## Listing tag siblings
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`GET /tag-siblings/<name>`
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"siblings": [
|
||||||
|
{
|
||||||
|
"tag": <tag>,
|
||||||
|
"occurrences": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": <tag>,
|
||||||
|
"occurrences": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
...where `<tag>` is a [tag resource](#tag).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Lists siblings of given tag, e.g. tags that were used in the same posts as
|
||||||
|
the given tag. `occurrences` field signifies how many times a given sibling
|
||||||
|
appears with given tag. Results are sorted by occurrences count and the
|
||||||
|
list is truncated to the first 50 elements. Doesn't use paging.
|
||||||
|
|
||||||
|
|
||||||
## Listing users
|
## Listing users
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,6 @@
|
||||||
|
|
||||||
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, TagMergingApi
|
from szurubooru.api.tag_api import TagListApi, TagDetailApi, TagMergeApi, TagSiblingsApi
|
||||||
from szurubooru.api.tag_category_api import TagCategoryListApi, TagCategoryDetailApi
|
from szurubooru.api.tag_category_api import TagCategoryListApi, TagCategoryDetailApi
|
||||||
from szurubooru.api.context import Context, Request
|
from szurubooru.api.context import Context, Request
|
||||||
|
|
|
@ -110,7 +110,7 @@ class TagDetailApi(BaseApi):
|
||||||
tags.export_to_json()
|
tags.export_to_json()
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
class TagMergingApi(BaseApi):
|
class TagMergeApi(BaseApi):
|
||||||
def post(self, ctx):
|
def post(self, ctx):
|
||||||
source_tag_name = ctx.get_param_as_string('remove', required=True) or ''
|
source_tag_name = ctx.get_param_as_string('remove', required=True) or ''
|
||||||
target_tag_name = ctx.get_param_as_string('merge-to', required=True) or ''
|
target_tag_name = ctx.get_param_as_string('merge-to', required=True) or ''
|
||||||
|
@ -131,3 +131,18 @@ class TagMergingApi(BaseApi):
|
||||||
ctx.session.commit()
|
ctx.session.commit()
|
||||||
tags.export_to_json()
|
tags.export_to_json()
|
||||||
return _serialize_tag_with_details(target_tag)
|
return _serialize_tag_with_details(target_tag)
|
||||||
|
|
||||||
|
class TagSiblingsApi(BaseApi):
|
||||||
|
def get(self, ctx, tag_name):
|
||||||
|
auth.verify_privilege(ctx.user, 'tags:view')
|
||||||
|
tag = tags.get_tag_by_name(tag_name)
|
||||||
|
if not tag:
|
||||||
|
raise tags.TagNotFoundError('Tag %r not found.' % tag_name)
|
||||||
|
result = tags.get_siblings(tag)
|
||||||
|
serialized_siblings = []
|
||||||
|
for sibling, occurrences in result:
|
||||||
|
serialized_siblings.append({
|
||||||
|
'tag': _serialize_tag(sibling),
|
||||||
|
'occurrences': occurrences
|
||||||
|
})
|
||||||
|
return {'siblings': serialized_siblings}
|
||||||
|
|
|
@ -53,7 +53,8 @@ def create_app():
|
||||||
tag_category_detail_api = api.TagCategoryDetailApi()
|
tag_category_detail_api = api.TagCategoryDetailApi()
|
||||||
tag_list_api = api.TagListApi()
|
tag_list_api = api.TagListApi()
|
||||||
tag_detail_api = api.TagDetailApi()
|
tag_detail_api = api.TagDetailApi()
|
||||||
tag_merging_api = api.TagMergingApi()
|
tag_merge_api = api.TagMergeApi()
|
||||||
|
tag_siblings_api = api.TagSiblingsApi()
|
||||||
password_reset_api = api.PasswordResetApi()
|
password_reset_api = api.PasswordResetApi()
|
||||||
|
|
||||||
app.add_error_handler(errors.AuthError, _on_auth_error)
|
app.add_error_handler(errors.AuthError, _on_auth_error)
|
||||||
|
@ -69,7 +70,8 @@ def create_app():
|
||||||
app.add_route('/tag-category/{category_name}', tag_category_detail_api)
|
app.add_route('/tag-category/{category_name}', tag_category_detail_api)
|
||||||
app.add_route('/tags/', tag_list_api)
|
app.add_route('/tags/', tag_list_api)
|
||||||
app.add_route('/tag/{tag_name}', tag_detail_api)
|
app.add_route('/tag/{tag_name}', tag_detail_api)
|
||||||
app.add_route('/tag-merge/', tag_merging_api)
|
app.add_route('/tag-merge/', tag_merge_api)
|
||||||
|
app.add_route('/tag-siblings/{tag_name}', tag_siblings_api)
|
||||||
app.add_route('/password-reset/{user_name}', password_reset_api)
|
app.add_route('/password-reset/{user_name}', password_reset_api)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
|
@ -98,6 +98,21 @@ def get_or_create_tags_by_names(names):
|
||||||
new_tags.append(new_tag)
|
new_tags.append(new_tag)
|
||||||
return related_tags, new_tags
|
return related_tags, new_tags
|
||||||
|
|
||||||
|
def get_siblings(tag):
|
||||||
|
tag_alias = sqlalchemy.orm.aliased(db.Tag)
|
||||||
|
pt_alias1 = sqlalchemy.orm.aliased(db.PostTag)
|
||||||
|
pt_alias2 = sqlalchemy.orm.aliased(db.PostTag)
|
||||||
|
result = db.session \
|
||||||
|
.query(tag_alias, sqlalchemy.func.count(tag_alias.tag_id)) \
|
||||||
|
.join(pt_alias1, pt_alias1.tag_id == tag_alias.tag_id) \
|
||||||
|
.join(pt_alias2, pt_alias2.post_id == pt_alias1.post_id) \
|
||||||
|
.filter(pt_alias2.tag_id == tag.tag_id) \
|
||||||
|
.filter(pt_alias1.tag_id != tag.tag_id) \
|
||||||
|
.group_by(tag_alias.tag_id) \
|
||||||
|
.order_by(tag_alias.post_count.desc()) \
|
||||||
|
.limit(50)
|
||||||
|
return result
|
||||||
|
|
||||||
def merge_tags(source_tag, target_tag):
|
def merge_tags(source_tag, target_tag):
|
||||||
db.session.execute(
|
db.session.execute(
|
||||||
sqlalchemy.sql.expression.update(db.PostTag) \
|
sqlalchemy.sql.expression.update(db.PostTag) \
|
||||||
|
|
|
@ -25,7 +25,7 @@ def test_ctx(
|
||||||
ret.context_factory = context_factory
|
ret.context_factory = context_factory
|
||||||
ret.user_factory = user_factory
|
ret.user_factory = user_factory
|
||||||
ret.tag_factory = tag_factory
|
ret.tag_factory = tag_factory
|
||||||
ret.api = api.TagMergingApi()
|
ret.api = api.TagMergeApi()
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def test_merging_without_usages(test_ctx, fake_datetime):
|
def test_merging_without_usages(test_ctx, fake_datetime):
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
import datetime
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, db, errors
|
||||||
|
from szurubooru.func import util, tags
|
||||||
|
|
||||||
|
def assert_results(result, expected_tag_names_and_occurrences):
|
||||||
|
actual_tag_names_and_occurences = {}
|
||||||
|
for item in result['siblings']:
|
||||||
|
tag_name = item['tag']['names'][0]
|
||||||
|
occurrences = item['occurrences']
|
||||||
|
actual_tag_names_and_occurences[tag_name] = occurrences
|
||||||
|
assert actual_tag_names_and_occurences == expected_tag_names_and_occurrences
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_ctx(
|
||||||
|
context_factory, config_injector, user_factory, tag_factory, post_factory):
|
||||||
|
config_injector({
|
||||||
|
'privileges': {
|
||||||
|
'tags:view': 'regular_user',
|
||||||
|
},
|
||||||
|
'thumbnails': {'avatar_width': 200},
|
||||||
|
'ranks': ['anonymous', 'regular_user'],
|
||||||
|
})
|
||||||
|
ret = util.dotdict()
|
||||||
|
ret.context_factory = context_factory
|
||||||
|
ret.user_factory = user_factory
|
||||||
|
ret.tag_factory = tag_factory
|
||||||
|
ret.post_factory = post_factory
|
||||||
|
ret.api = api.TagSiblingsApi()
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def test_unused(test_ctx):
|
||||||
|
db.session.add(test_ctx.tag_factory(names=['tag']))
|
||||||
|
result = test_ctx.api.get(
|
||||||
|
test_ctx.context_factory(
|
||||||
|
user=test_ctx.user_factory(rank='regular_user')), 'tag')
|
||||||
|
assert_results(result, {})
|
||||||
|
|
||||||
|
def test_used_alone(test_ctx):
|
||||||
|
tag = test_ctx.tag_factory(names=['tag'])
|
||||||
|
post = test_ctx.post_factory()
|
||||||
|
post.tags = [tag]
|
||||||
|
db.session.add_all([post, tag])
|
||||||
|
result = test_ctx.api.get(
|
||||||
|
test_ctx.context_factory(
|
||||||
|
user=test_ctx.user_factory(rank='regular_user')), 'tag')
|
||||||
|
assert_results(result, {})
|
||||||
|
|
||||||
|
def test_used_with_others(test_ctx):
|
||||||
|
tag1 = test_ctx.tag_factory(names=['tag1'])
|
||||||
|
tag2 = test_ctx.tag_factory(names=['tag2'])
|
||||||
|
post = test_ctx.post_factory()
|
||||||
|
post.tags = [tag1, tag2]
|
||||||
|
db.session.add_all([post, tag1, tag2])
|
||||||
|
result = test_ctx.api.get(
|
||||||
|
test_ctx.context_factory(
|
||||||
|
user=test_ctx.user_factory(rank='regular_user')), 'tag1')
|
||||||
|
assert_results(result, {'tag2': 1})
|
||||||
|
result = test_ctx.api.get(
|
||||||
|
test_ctx.context_factory(
|
||||||
|
user=test_ctx.user_factory(rank='regular_user')), 'tag2')
|
||||||
|
assert_results(result, {'tag1': 1})
|
||||||
|
|
||||||
|
def test_used_with_multiple_others(test_ctx):
|
||||||
|
tag1 = test_ctx.tag_factory(names=['tag1'])
|
||||||
|
tag2 = test_ctx.tag_factory(names=['tag2'])
|
||||||
|
tag3 = test_ctx.tag_factory(names=['tag3'])
|
||||||
|
post1 = test_ctx.post_factory()
|
||||||
|
post2 = test_ctx.post_factory()
|
||||||
|
post1.tags = [tag1, tag2, tag3]
|
||||||
|
post2.tags = [tag1, tag3]
|
||||||
|
db.session.add_all([post1, post2, tag1, tag2, tag3])
|
||||||
|
result = test_ctx.api.get(
|
||||||
|
test_ctx.context_factory(
|
||||||
|
user=test_ctx.user_factory(rank='regular_user')), 'tag1')
|
||||||
|
assert_results(result, {'tag2': 1, 'tag3': 2})
|
||||||
|
result = test_ctx.api.get(
|
||||||
|
test_ctx.context_factory(
|
||||||
|
user=test_ctx.user_factory(rank='regular_user')), 'tag2')
|
||||||
|
assert_results(result, {'tag1': 1, 'tag3': 1})
|
||||||
|
result = test_ctx.api.get(
|
||||||
|
test_ctx.context_factory(
|
||||||
|
user=test_ctx.user_factory(rank='regular_user')), 'tag3')
|
||||||
|
assert_results(result, {'tag1': 2, 'tag2': 1})
|
||||||
|
|
||||||
|
def test_trying_to_retrieve_non_existing(test_ctx):
|
||||||
|
with pytest.raises(tags.TagNotFoundError):
|
||||||
|
test_ctx.api.get(
|
||||||
|
test_ctx.context_factory(
|
||||||
|
user=test_ctx.user_factory(rank='regular_user')), '-')
|
||||||
|
|
||||||
|
def test_trying_to_retrieve_without_privileges(test_ctx):
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
test_ctx.api.get(
|
||||||
|
test_ctx.context_factory(
|
||||||
|
user=test_ctx.user_factory(rank='anonymous')), '-')
|
Loading…
Reference in New Issue